Compare commits

...
31 Commits
Author SHA1 Message Date
ddmitry 0bdce356e3 Merge pull request 'Запуск курса: JupyterLab и VS Code в браузере' (#5) from t3/browser-vscode-setup into main 2026-10-21 12:22:42 +03:00
ddadmin db8f275ea0 docs(course): make JupyterLab the primary notebook interface 2026-10-21 10:22:00 +02:00
ddadmin 863d658a5f fix(trino): bound memory for the learning stack 2026-10-19 10:47:40 +02:00
ddadmin 21f3570b89 docs: clarify learner workflow and resource requirements 2026-10-19 09:48:24 +02:00
ddadmin 78cf7b5fae fix(stack): use pinned MinIO image with registry fallback 2026-10-19 09:26:12 +02:00
ddadmin 7d451a7ce9 feat(ide): add browser VS Code for course notebooks 2026-10-18 22:39:39 +02:00
ddadmin bf3241124c fix(course): clarify history and fix snapshot expiration
Set an explicit expiration cutoff because retain_last alone preserves fresh snapshots under the default five-day age threshold. Add result checks, distinguish time travel from rollback, and explain schema metadata versions.

Validated changed cells with Spark 3.5.1 and Iceberg 1.10.0 in an isolated container: 10 to 2 snapshots, 1300 rows preserved, expired time travel rejected, repeat execution in UTC and America/New_York. Notebook validation, Python syntax, local links and diff checks pass.
2026-09-20 16:14:53 -04:00
ddmitry 872b876be1 Merge pull request 'feat(course): практика настройки SparkSession в модуле 3' (#4) from 2-spark-session-practice into main 2026-09-15 16:20:45 +03:00
ddadmin cc0cd3857d docs(course): mark module 3 work complete 2026-09-15 09:20:04 -04:00
ddadmin 9d5b0b716e docs(course): integrate Spark setup into the module flow 2026-09-15 09:12:28 -04:00
ddadmin 16f76d63fc docs(readme): remove module-specific practice details 2026-09-15 09:04:39 -04:00
ddadmin 7c5e976405 feat(course): add SparkSession setup practice to module 3 2026-09-15 09:01:41 -04:00
ddmitry 00942c255f Merge pull request 'fix(spark): подключение всех ноутбуков к кластеру' (#3) from 1-spark-session-master into main 2026-09-15 15:41:58 +03:00
ddadmin 2cdecd82f8 docs(course): record Spark session fix completion 2026-09-15 08:41:06 -04:00
ddadmin a5244f8a0b docs(spark): simplify session setup explanations 2026-09-15 08:36:34 -04:00
ddadmin 91a5c85469 fix(spark): исправлено подключение ноутбуков к кластеру
- Зачем: модули 2–8 в новом процессе выбирали local[*] вместо Spark-кластера.
- Что: master перенесён в общий spark-defaults.conf, smoke требует точный адрес кластера, документация и планы синхронизированы.
- Проверка: фактическая инициализация всех восьми ноутбуков, повторный getOrCreate, распределённые вычисления, Iceberg/MinIO write-read-cleanup и docker compose config прошли.
2026-09-15 08:18:33 -04:00
ddadmin a085b2fc55 fix(course): исправлена семантика Iceberg snapshots
- Зачем: объяснения CREATE OR REPLACE и rollback расходились с поведением Iceberg 1.10.0 и ломали повторный запуск упражнений.
- Что: исправлены материалы и планы модулей 2, 4, 5, 7 и 8; одноразовые таблицы сбрасываются через DROP TABLE PURGE.
- Проверка: JSON и Python AST валидны; на Spark 3.5.1, Iceberg 1.10.0, JDBC/MinIO и Trino 478 подтверждены rollback, replace, time travel и повторный сброс до одного snapshot-а.
2026-09-15 07:35:39 -04:00
ddadmin ff474f05f6 docs(course): add development roadmap and issue links 2026-09-15 07:16:19 -04:00
ddadmin 34a3cb309c docs(agents): добавлена конфигурация инженерных навыков 2026-09-15 07:07:47 -04:00
ddadmin 2e961fd0c7 fix(minio): восстановлена загрузка образов
- Зачем: зафиксированные теги больше не доступны в Docker Hub для чистой установки.
- Что: MinIO Server и mc переведены на официальный Quay с сохранением версий; документация синхронизирована.
- Проверка: docker compose config; docker compose pull minio minio-init; docker compose up -d; MinIO live, init, Trino и Jupyter API.
2026-09-14 14:32:31 -04:00
ddadmin 1bf8a16111 fix(jupyter): исправлено подключение из VS Code
- Зачем: VS Code через Remote-SSH не мог создать kernel из-за XSRF-проверки при отключённой аутентификации.
- Что: Jupyter ограничен loopback-интерфейсом, а локальный режим без auth и XSRF задан в Docker Compose; документация синхронизирована.
- Проверка: docker compose config; JupyterLab GET 200; kernel POST 201 и DELETE 204 без token/XSRF; порт слушает только 127.0.0.1.
2026-09-14 11:02:49 -04:00
ddadmin df39ff9e64 merge: курс «Lakehouse без магии» v1
Вливает ветку docs/lab-materials: 8 модулей, стартовая документация,
вспомогательные материалы (glossary, mentor notes, шпаргалка).
2026-03-08 01:07:02 +03:00
ddadminandClaude Opus 4.6 0ae83cecdb docs(course): добавлены glossary, mentor notes, шпаргалка и переписан README
- Зачем:
  - закрыты 3 вспомогательных артефакта из course_program.md §3.3: glossary, cheat sheet, mentor notes.
  - README переписан с фокусом на ценность для студента.
- Что:
  - создан docs/glossary.md (16 терминов, сгруппированных по темам с параллелями к DWH).
  - создан docs/mentor_notes.md (тайминг, типичные вопросы, checkpoint-ы, формат «менти работает сам»).
  - добавлена секция «Краткая шпаргалка» в docs/stack_reference.md (S3-пути, таблицы, SQL-команды, маунты).
  - README.md переписан: лид с навыками, убрано дублирование со stack_reference.
  - обновлены перекрёстные ссылки в AGENTS.md, course_program.md, maintainer_guide.md.
- Проверка:
  - все ссылки между документами валидны (glossary.md, mentor_notes.md существуют).
  - термины glossary и команды шпаргалки верифицированы по содержимому ноутбуков.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:04:26 +03:00
ddadmin 4bea2af9d2 feat(module-08): реализован модуль по обслуживанию таблиц и финальная практика
- Зачем:
  - обучение студентов базовому обслуживанию Iceberg-таблиц (compaction, expire_snapshots) и закрепление навыков всего курса.
- Что:
  - создан notebooks/08_maintenance_and_final_lab.ipynb с обзором проблемы мелких файлов и её решения.
  - добавлена финальная сквозная практика (raw -> bronze -> silver -> Trino -> schema evolution -> обслуживание) в namespace lakehouse.final.
  - в финальную практику включены педагогические подсказки и конкретные задания (trip_duration_minutes).
  - статус модуля 8 в планах обновлен до Ready for validation.
- Проверка:
  - ручная проверка выполнения ячеек в ноутбуке и синтаксиса stored procedures Iceberg.
2026-03-08 00:33:40 +03:00
ddadmin 9ffb9b80e0 feat(module-07): реализован модуль по schema evolution и time travel
- Зачем:
  - обучение студентов безопасным изменениям таблиц и механизмам отката Iceberg.
- Что:
  - создан notebooks/07_safe_table_changes.ipynb с демонстрацией ALTER TABLE, VERSION AS OF и rollback.
  - в ноутбук добавлена самостоятельная работа на демо-таблице и чекпоинт для самопроверки.
  - статус модуля 7 в планах обновлен до Ready for validation.
- Проверка:
  - ручная верификация структуры ноутбука и синтаксиса Spark SQL/trino_query.
2026-03-08 00:19:06 +03:00
ddadmin 65d57ec6d6 feat(module-06): реализован модуль по совместной работе Spark и Trino
- Зачем:
  - продемонстрировать возможность доступа нескольких движков к одной Iceberg-таблице через общий каталог.
- Что:
  - создан notebooks/06_spark_and_trino_on_same_table.ipynb с примерами write (Spark) -> read (Trino).
  - в jupyter/Dockerfile добавлен клиент trino.
  - в START_HERE.md и docs/stack_reference.md добавлена инструкция по подключению DBeaver к Trino.
  - статус модуля 6 в планах обновлен до Ready for validation.
- Проверка:
  - ручная проверка выполнения ячеек в ноутбуке (Python trino client).
  - проверка наличия всех инструкций в документации.
2026-03-07 23:58:36 +03:00
ddadminandClaude Opus 4.6 68c530c855 refactor(course): перенесена загрузка taxi_zone_lookup из домашки в демо-секцию
- Зачем:
  - демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих — модуль 5 использует lookup в демо-коде для JOIN.
- Что:
  - модуль 4: секция 8 из домашки (5 пустых ячеек) превращена в демо с заполненным кодом (чтение CSV, CTAS).
  - модуль 4: новая домашка (секция 9) — осмотр metadata lookup-таблицы, сравнение с основной.
  - модуль 4: секции перенумерованы (9→10, 10→11), завершение упоминает обе bronze-таблицы.
  - модуль 5: assert-сообщение для lookup обновлено (убрана ссылка на «самостоятельное задание»).
  - планы модулей 4 и 5: обновлены дизайн-решения, структура секций и раздел рисков.
- Проверка:
  - прогнать готовые ячейки модуля 4 → lookup-таблица создаётся автоматически → модуль 5 проходит без ошибок.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:47:05 +03:00
ddadmin bcf7975227 feat(notebooks): реализован модуль 5 по слою silver и трансформациям
- Зачем:
  - обучение переходу от bronze-таблицы к очищенному и обогащенному silver-слою.
- Что:
  - создан notebooks/05_silver_layer.ipynb с пайплайном фильтрации, нормализации и JOIN.
  - в ноутбук добавлены проверки качества на уровнях строк, схемы и агрегатов.
  - статус модуля 5 в планах обновлен до Ready for validation.
- Проверка:
  - ручная проверка структуры ноутбука и корректности Spark-трансформаций.
2026-03-07 23:32:43 +03:00
ddadmin 4a736f6c4e docs(plan): добавлен план модуля 8 по обслуживанию таблиц
- Зачем:
  - нужно показать базовые операции обслуживания Iceberg-таблиц и финальную практику.
- Что:
  - создан файл plans/module-08-maintenance-and-final-lab.md с планом модуля.
  - обновлен plans/README.md с записью о новом модуле в таблицу содержания.
- Проверка:
  - проверка согласованности ссылок и структуры документации.
2026-03-07 23:13:37 +03:00
ddadmin a31a020a76 docs(plan): добавлен план модуля 7 по безопасным изменениям таблиц
- Зачем:
  - нужно продемонстрировать возможности Iceberg по schema evolution и time travel.
- Что:
  - создан файл plans/module-07-safe-table-changes.md с планом модуля.
  - обновлен plans/README.md с записью о новом модуле в таблицу содержания.
- Проверка:
  - проверка согласованности ссылок и структуры документации.
2026-03-07 22:27:54 +03:00
ddadmin 39b1a5618c docs(plan): добавлен план модуля 6 по Spark и Trino на одной таблице
- Зачем:
  - нужно продемонстрировать возможность одновременной работы Spark и Trino с одной Iceberg-таблицей.
- Что:
  - создан файл plans/module-06-spark-and-trino-on-same-table.md с планом модуля.
  - обновлен plans/README.md с записью о новом модуле в таблицу содержания.
- Проверка:
  - проверка согласованности ссылок и структуры документации.
2026-03-07 21:30:26 +03:00
44 changed files with 6576 additions and 384 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Копировать в .env необязательно: это значения по умолчанию.
# Если .env уже есть, добавь нужную настройку в него, сохранив остальные.
# GHCR — основной источник MinIO. Резерв того же автора и digest: docker.io.
MINIO_REGISTRY=ghcr.io
# На Linux при необходимости укажи свой UID и GID (команды: id -u; id -g).
NB_UID=1000
NB_GID=100
+16
View File
@@ -11,6 +11,8 @@
- `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам. - `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам.
- `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope. - `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope.
- `docs/course_program.md` — модульная структура курса и состав учебных материалов. - `docs/course_program.md` — модульная структура курса и состав учебных материалов.
- `docs/glossary.md` — справочник терминов курса.
- `docs/mentor_notes.md` — заметки для ведения курса с ментором (опционально).
- `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений. - `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений.
- `plans/` — внутренние living docs; не источник истины для студентского маршрута. - `plans/` — внутренние living docs; не источник истины для студентского маршрута.
- `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса. - `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса.
@@ -58,3 +60,17 @@
- Уважай существующие изменения пользователя и не откатывай их без запроса. - Уважай существующие изменения пользователя и не откатывай их без запроса.
- Предпочитай минимальные изменения, которые делают курс проще и устойчивее для новичка. - Предпочитай минимальные изменения, которые делают курс проще и устойчивее для новичка.
- Если непонятно, какой маршрут сейчас актуален, доверяй `START_HERE.md` и `docs/course_program.md`, а не старым HOWTO или планам. - Если непонятно, какой маршрут сейчас актуален, доверяй `START_HERE.md` и `docs/course_program.md`, а не старым HOWTO или планам.
## Agent skills
### Трекер задач
Задачи ведутся в Gitea Issues этого репозитория через `tea`. Подробнее: `docs/agents/issue-tracker.md`.
### Метки триажа
В репозитории используются пять стандартных меток триажа. Подробнее: `docs/agents/triage-labels.md`.
### Документация предметной области
Документация предметной области устроена по схеме `single-context`. Подробнее: `docs/agents/domain.md`.
+64 -61
View File
@@ -1,23 +1,31 @@
# Lakehouse без магии: локальный стенд и учебные материалы # Lakehouse без магии
Этот репозиторий объединяет: Практический курс, после которого ты будешь уверенно работать с Lakehouse-стеком: поднимать стенд, строить пайплайн `raw -> bronze -> silver`, читать одну таблицу из двух движков и не бояться изменений в данных.
- локальный Lakehouse-стенд на `Spark + Trino + Iceberg + MinIO + PostgreSQL`; ## Что ты получишь
- учебный курс `Lakehouse без магии`, который использует этот стенд как практическую среду;
- стартовые ноутбуки и demo-скрипты для первых экспериментов.
Репозиторий рассчитан не на «универсальную платформу для всего», а на понятную локальную песочницу, где можно руками пройти путь от запуска стенда до чтения одной и той же Iceberg-таблицы из `Spark` и `Trino`. После прохождения 8 модулей ты умеешь:
## Архитектура 1. **Поднимать и диагностировать** локальный Lakehouse-стенд — не по инструкции, а с пониманием, что и зачем работает.
2. **Объяснять архитектуру** `storage + catalog + compute` — и видеть, как знакомые концепции из PostgreSQL/Greenplum ложатся на новый стек.
3. **Строить пайплайн** `raw -> bronze -> silver` на реальном датасете NYC Taxi — с проверками качества и воспроизводимостью.
4. **Работать с двумя движками** — записывать данные через Spark, читать через Trino, и понимать, почему это работает без копирования.
5. **Безопасно менять таблицы** — schema evolution, time travel, rollback к предыдущему состоянию вместо паники.
6. **Обслуживать таблицы** — compaction и expire_snapshots, с пониманием параллелей к VACUUM/REORGANIZE.
Ниже показана упрощённая рабочая схема стенда: пользователь входит через `JupyterLab` и `Trino UI / CLI`, а `Spark` и `Trino` независимо работают поверх общего `storage` и общего `catalog`. Курс рассчитан на `~12-15 часов` самостоятельной работы. Каждый модуль: объяснение, демонстрация, самостоятельное задание, checkpoint.
## Стек
Всё работает локально в Docker. Для первой сборки и скачивания учебных данных
нужен интернет; облачный аккаунт не требуется.
```mermaid ```mermaid
graph TB graph TB
U[Student / engineer] U[Student / engineer]
subgraph Access subgraph Access
J[JupyterLab<br/>notebooks / PySpark] J[JupyterLab<br/>Notebooks / PySpark]
Q[Trino UI / CLI<br/>ad hoc SQL] Q[Trino UI / CLI<br/>ad hoc SQL]
end end
@@ -41,78 +49,73 @@ graph TB
T --> M T --> M
``` ```
Коротко по ролям: | Компонент | Роль в стенде |
| --- | --- |
| `MinIO` | Storage — хранит данные и служебные файлы Iceberg |
| `PostgreSQL` | Catalog — метаданные JDBC-каталога `lakehouse` |
| `Spark` | Compute — ETL, запись и чтение Iceberg-таблиц |
| `Trino` | Compute — ad hoc SQL, чтение тех же таблиц |
| `JupyterLab` | Основной интерфейс — ноутбуки курса, Python и терминал внутри Docker |
| `code-server` | VS Code в браузере — альтернативный интерфейс для тех же ноутбуков |
- `MinIO` хранит данные и служебные файлы Iceberg. Ноутбуки подключаются к Spark-кластеру с
- `PostgreSQL` хранит метаданные JDBC-каталога `lakehouse`. [готовыми настройками стенда](./docs/stack_reference.md#spark).
- `Spark` и `Trino` работают как два compute-движка поверх одного storage и одного catalog.
- `JupyterLab` служит основной точкой входа в практическую часть курса.
- `Trino UI / CLI` даёт отдельную точку входа для ad hoc SQL и проверки таблиц.
## С чего начать ## С чего начать
Если ты заходишь в репозиторий впервые, используй такой маршрут: 1. Открой [START_HERE.md](./START_HERE.md) — подготовка, запуск и первый ноутбук
с ожидаемым результатом.
2. Продолжи по [маршруту из восьми модулей](./docs/course_program.md#маршрут-по-модулям).
3. Между занятиями используй [памятку учащегося](./docs/study_workflow.md):
остановка, продолжение работы и диагностика.
1. Открой [START_HERE.md](./START_HERE.md) для первого запуска стенда и базовой диагностики. Для одного учащегося рекомендуем компьютер с 16 ГБ RAM, не менее 4 CPU и
2. После старта стенда выполни `notebooks/01_environment_and_smoke_test.ipynb`. ориентировочно 8 ГБ памяти для Docker, а также от 20 ГБ свободного места перед сборкой.
3. Для структуры курса смотри [docs/course_program.md](./docs/course_program.md). Trino ограничен 2 ГиБ памяти контейнера и 1 ГиБ Java heap.
Подробности и границы проверки — в [системных требованиях](./START_HERE.md#системные-требования).
Если нужен только краткий запуск, из корня репозитория достаточно: Краткий запуск из корня репозитория после подготовки по `START_HERE`:
```bash ```bash
docker compose pull minio minio-init postgres trino
docker compose build docker compose build
docker compose up -d docker compose up -d
docker compose ps docker compose ps -a
``` ```
Основные UI после старта: MinIO скачивается готовым из GHCR, его версия и digest закреплены. Если какой-то
источник недоступен из твоей сети, начни с
[инструкции по доступу к репозиториям](./docs/network_access.md).
- Spark Master UI: `http://localhost:8080` После старта:
- Trino UI: `http://localhost:8090`
- MinIO Console: `http://localhost:9001`
- JupyterLab: `http://localhost:8888`
Полный onboarding, диагностика и reset-сценарии находятся в `START_HERE.md`. | UI | Адрес |
## Как устроен репозиторий
| Где | Что лежит |
| --- | --- | | --- | --- |
| `docker-compose.yml` | состав сервисов стенда, порты, сети, init-контейнеры | | JupyterLab | `http://localhost:8888` |
| `spark/` | Dockerfile и конфиг Spark для Iceberg + MinIO | | VS Code в браузере (по желанию) | `http://localhost:8889` |
| `trino/` | каталог `lakehouse` и настройки Trino | | Spark Master | `http://localhost:8080` |
| `jupyter/` | образ JupyterLab на базе Spark-образа | | Trino | `http://localhost:8090` |
| `notebooks/` | практические ноутбуки курса | | MinIO Console | `http://localhost:9001` |
| `src/` | smoke-скрипты, SQL-демо и helper-логика |
| `docs/` | PRD, программа курса, reference-документы |
| `plans/` | внутренние living docs по развитию материалов |
## Основные документы для прохождения Открой ноутбук в JupyterLab: Python работает внутри контейнера, локальный
Python и venv не нужны. JupyterLab доступен только с хоста стенда.
Первый запуск ноутбука описан в [START_HERE.md](./START_HERE.md#как-работать-с-ноутбуками).
- [START_HERE.md](./START_HERE.md) — первый маршрут для студента. VS Code запускается вместе со стендом. Его можно использовать по желанию;
он совместим не со всеми сочетаниями браузера, расширений и средств защиты.
[Выбор ядра и доступ к удалённому стенду](./docs/stack_reference.md#vs-code-в-браузере).
## Документы для прохождения
- [START_HERE.md](./START_HERE.md) — подготовка компьютера, запуск и первый результат.
- [docs/study_workflow.md](./docs/study_workflow.md) — памятка для каждого занятия и помощь при ошибках.
- [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов. - [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов.
- [docs/stack_reference.md](./docs/stack_reference.md) — технический reference по сервисам, портам, конфигам и smoke-тестам. - [docs/stack_reference.md](./docs/stack_reference.md) — порты, команды, конфиги, шпаргалка.
- [docs/glossary.md](./docs/glossary.md) — справочник терминов (storage, catalog, compute, snapshot и др.).
## Что уже можно делать в стенде ## Менторство
- поднять локальный кластер `Spark` с двумя worker-ами; Курс рассчитан на самостоятельное прохождение, но если хочешь разобрать темы глубже с ментором по Data Engineering — напиши: [@dementev_dev](https://t.me/dementev_dev).
- создать Iceberg-таблицу из `Spark`;
- прочитать ту же таблицу из `Trino`;
- пройти базовый smoke test через ноутбук или demo-скрипты;
- использовать стенд как основу для следующих модулей курса.
## Куда смотреть за техническими деталями
- [docs/stack_reference.md](./docs/stack_reference.md) — сервисы, порты, доступ, reset, smoke tests;
- [spark/spark-defaults.conf](./spark/spark-defaults.conf) — конфигурация Spark-каталога `lakehouse`;
- [trino/catalog/lakehouse.properties](./trino/catalog/lakehouse.properties) — конфигурация каталога Trino;
- [docker-compose.yml](./docker-compose.yml) — фактический состав стенда.
## Автор и менторство
Этот репозиторий и материалы курса можно проходить самостоятельно, но при желании их можно разбирать вместе с автором как с ментором по `Data Engineering`.
Если хочешь глубже пройти темы `Spark`, `Trino`, `Iceberg`, `Lakehouse` и связанные практики по `DE`, напиши: [@dementev_dev](https://t.me/dementev_dev).
## Лицензия ## Лицензия
+194 -82
View File
@@ -1,31 +1,88 @@
# START HERE # START HERE
Этот документ нужен для первого входа в курс: поднять стенд, проверить сервисы, открыть интерфейсы, выполнить базовый smoke test и подготовить локальный data bundle для следующих модулей. Здесь ты подготовишь окружение и выполнишь первое вычисление в Spark.
К концу запуска в ноутбуке появится `Spark cluster smoke test passed.`, а результат
сохранится в файле на твоем компьютере. Smoke test — короткая проверка, что
основные компоненты работают вместе.
Если стенд уже собран, открой [памятку для следующего занятия](./docs/study_workflow.md).
Она объясняет, как продолжить работу, что сохраняется и куда смотреть при ошибках.
## Маршрут прохождения ## Маршрут прохождения
1. Проверить prerequisites. 1. Подготовь Docker, ресурсы и локальную копию репозитория.
2. Собрать и поднять стенд. 2. Скачай образы, собери окружение и проверь запуск сервисов.
3. Убедиться, что контейнеры живы. 3. Открой первый ноутбук в JupyterLab и проверь результат вычисления.
4. Открыть основные UI. 4. Продолжи по [программе курса](./docs/course_program.md#маршрут-по-модулям).
5. Зайти в Jupyter и выполнить `notebooks/01_environment_and_smoke_test.ipynb`.
6. Перед Модулем 3 подготовить учебный датасет в `./data/nyc_taxi`.
7. При проблемах использовать логи и шаги диагностики из этого документа.
## Prerequisites Датасет Taxi понадобится перед Модулем 3, DBeaver необязателен и понадобится
только перед Модулем 6. Для первого ноутбука их готовить не нужно.
Нужно заранее установить: <a id="prerequisites"></a>
- Docker; ## Что подготовить
- Docker Compose;
- современный браузер для UI;
- свободные порты на хосте.
Рекомендуемые ресурсы хоста: - Docker с запущенным движком и Docker Compose 2.17+ с BuildKit.
- Современный браузер; основной интерфейс курса — JupyterLab на `localhost:8888`.
- Git для скачивания репозитория или его распакованную ZIP-копию.
- Свободные порты из таблицы ниже и доступ к источникам образов и зависимостей.
- не менее `4 vCPU`; Команды ниже рассчитаны на Bash: терминал Linux/macOS или терминал Linux
- не менее `10 GB RAM`, иначе `Spark`, `Trino` и `Jupyter` могут стартовать нестабильно; в WSL2 на Windows с включенной интеграцией Docker Desktop. Выполняй их на
- хотя бы `8-10 GB` свободного места под образы и контейнеры. машине, где работает Docker. Терминал внутри JupyterLab для запуска
`docker compose` не подходит.
### Системные требования
Ориентир для одного учащегося и основного набора Taxi за три месяца:
| Ресурс | Рекомендация | Что проверить |
| --- | --- | --- |
| Процессор | Не менее 4 CPU, доступных Docker | В Docker Desktop/WSL2 может стоять меньший лимит |
| Память Docker | Ориентир — 8 ГБ RAM; 10 ГБ дают дополнительный запас | Это память для среды Docker, а не общий объем памяти компьютера |
| Память компьютера | 16 ГБ RAM или больше | Оставь память ОС, браузеру и другим приложениям |
| Свободное место | Не менее 20 ГБ перед первой сборкой; 30 ГБ с запасом | На диске Docker и в папке репозитория: образы, кэш сборки, исходные данные и таблицы |
Это рекомендуемый бюджет для одного активного ноутбука, а не измеренный минимум.
На Windows с WSL2 по умолчанию доступно 50% RAM компьютера: при 16 ГБ это 8 ГБ
для всей WSL2, включая Docker. Лимит можно настроить, это не аппаратное
ограничение. См. [настройки WSL2](https://learn.microsoft.com/en-us/windows/wsl/wsl-config#main-wsl-settings)
и [ресурсы Docker Desktop](https://docs.docker.com/desktop/settings-and-maintenance/settings/#resources).
Trino ограничен 2 ГиБ памяти контейнера и 1 ГиБ Java heap. Проверка запросов
на данных курса описана в [справочнике](./docs/stack_reference.md#ресурсы-и-проверенная-среда).
Полный курс под общим жестким лимитом Docker 8 ГБ пока не проверен. В WSL2 часть
памяти также нужна самой Linux-среде; закрой другие тяжелые задачи на время урока.
Место внутри диска Docker и свободное место на диске компьютера — отдельные ограничения.
С добавлением VS Code образ окружения вырос примерно на 1,2 ГБ относительно
Jupyter; базовые слои у них общие. MinIO скачивается готовым, компилировать его
не нужно. [Основания оценок и границы проверки](./docs/stack_reference.md#ресурсы-и-проверенная-среда).
Python, Java, Jupyter и расширения VS Code устанавливаются при сборке образов.
На компьютере не нужно создавать venv или устанавливать Python-пакеты.
Для первой сборки нужен интернет. Если ты работаешь из РФ или сети с
ограничениями, при ошибке скачивания используй
[инструкцию по доступу к репозиториям](./docs/network_access.md). Она различает
проблемы скачивания образов и зависимостей внутри сборки.
### Проверка до сборки
Открой терминал в папке репозитория — там, где лежит `docker-compose.yml`:
```bash
docker version
docker compose version
docker info --format 'CPUs={{.NCPU}} MemoryBytes={{.MemTotal}}'
docker compose config --quiet
```
В `docker version` должны быть разделы Client и Server, версия Compose — не
ниже 2.17. `docker info` показывает ресурсы, которые видит Docker; память дана
в байтах (8 ГиБ — 8 589 934 592 байта). Последняя команда при успехе
ничего не печатает. Если Docker не отвечает или конфигурация не читается,
исправь эту ошибку до сборки.
## Порты стенда ## Порты стенда
@@ -38,18 +95,46 @@
| MinIO API | `http://localhost:9000` | S3-compatible endpoint | | MinIO API | `http://localhost:9000` | S3-compatible endpoint |
| MinIO Console | `http://localhost:9001` | Просмотр бакетов и файлов | | MinIO Console | `http://localhost:9001` | Просмотр бакетов и файлов |
| JupyterLab | `http://localhost:8888` | Основная точка входа в практику | | JupyterLab | `http://localhost:8888` | Основная точка входа в практику |
| VS Code в браузере | `http://localhost:8889` | Альтернативный интерфейс, по желанию |
| PostgreSQL | `localhost:5432` | JDBC-каталог Iceberg, нужен для диагностики | | PostgreSQL | `localhost:5432` | JDBC-каталог Iceberg, нужен для диагностики |
## Быстрый запуск ## Быстрый запуск
Все команды выполняются из корня репозитория. Все команды выполняются из корня репозитория.
### 1. Собрать образы На Linux и в WSL2 проверь владельца файлов:
```bash ```bash
id -u
id -g
```
Это UID и GID — числовые идентификаторы пользователя и группы. В контейнере по
умолчанию используются `1000` и `100`. Если значения отличаются, до сборки
создай `.env` в корне репозитория и укажи `NB_UID` и `NB_GID` со своими числами.
Например, для вывода `1001` и `1001`:
```dotenv
NB_UID=1001
NB_GID=1001
```
Если `.env` уже есть, измени только эти строки, сохранив остальные настройки.
Файл остается между занятиями. Образец — [`.env.example`](./.env.example).
На macOS с Docker Desktop обычно подходят значения по умолчанию.
### 1. Скачать готовые образы и собрать окружение
```bash
docker compose pull minio minio-init postgres trino
docker compose build docker compose build
``` ```
Первая команда проверяет загрузку готовых сервисов до сборки окружения.
MinIO уже собран; для Spark, Jupyter и IDE первая сборка скачивает зависимости
и зависит по времени от сети и компьютера. Дождись завершения без ошибок;
не переходи к запуску после неудачной сборки. Повторная сборка использует кэш Docker.
### 2. Поднять стенд ### 2. Поднять стенд
```bash ```bash
@@ -59,33 +144,36 @@ docker compose up -d
### 3. Проверить статус контейнеров ### 3. Проверить статус контейнеров
```bash ```bash
docker compose ps docker compose ps -a
``` ```
Ожидаемое состояние: Ожидаемое состояние:
- сервисы `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter` находятся в состоянии `Up`; - `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`,
- однократные init-контейнеры вроде `minio-init` и `iceberg-catalog-init` могут завершиться после успешной инициализации. `trino`, `jupyter`, `ide` работают (`Up`); у `postgres-iceberg`, `trino` и `ide`
дождись `healthy`;
- `minio-init` и `iceberg-catalog-init` заканчивают работу со статусом `Exited (0)`.
Это успешная разовая подготовка хранилища и каталога. `Exited (1)` и другой
ненулевой код требуют проверки логов.
Флаг `-a` показывает и завершившиеся контейнеры. Сам по себе статус `Up` еще
не доказывает, что вычисления работают: дальше проверим их в ноутбуке.
## Куда заходить после старта ## Куда заходить после старта
Открой в браузере: | Что открыть | Что должно быть видно |
| --- | --- |
| [Spark Master](http://localhost:8080) | Два worker-а в состоянии `ALIVE` |
| [MinIO Console](http://localhost:9001) | После входа `minioadmin` / `minioadmin` виден бакет `lakehouse` — контейнер для файлов курса |
| [JupyterLab](http://localhost:8888) | Слева список ноутбуков `01_…` — `08_…` |
- `http://localhost:8080` для `Spark Master UI`; В [Trino UI](http://localhost:8090) для входа достаточно имени, например `student`;
- `http://localhost:8090` для `Trino UI`; пароль в стенде не задан. Сейчас достаточно проверить, что интерфейс открывается.
- `http://localhost:9001` для `MinIO Console`; Чтение общих таблиц через Trino разберем в Модуле 6.
- `http://localhost:8888` для `JupyterLab`.
Для входа в `MinIO Console` используй: Этот маршрут предполагает Docker на твоем компьютере: `localhost` относится
к машине с браузером. Доступ к JupyterLab на удаленном сервере описан отдельно
- логин: `minioadmin`; в [инструкции по SSH-туннелю](./docs/stack_reference.md#jupyterlab).
- пароль: `minioadmin`.
Если интерфейсы открываются, переходи в Jupyter и запускай:
```text
notebooks/01_environment_and_smoke_test.ipynb
```
## Роли сервисов в стенде ## Роли сервисов в стенде
@@ -95,59 +183,75 @@ notebooks/01_environment_and_smoke_test.ipynb
| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg | | `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg |
| `Spark` | `compute`: выполняет PySpark и Spark SQL задания | | `Spark` | `compute`: выполняет PySpark и Spark SQL задания |
| `Trino` | `compute`: читает те же таблицы через общий каталог | | `Trino` | `compute`: читает те же таблицы через общий каталог |
| `Jupyter` | Точка входа в учебные ноутбуки | | `JupyterLab` | Интерфейс для учебных ноутбуков |
## Как работать в Jupyter ## Как работать с ноутбуками
- `./notebooks` смонтирован в контейнер как `/opt/work`; Ноутбук — файл с пояснениями, кодом и сохраненным выводом. Ядро — процесс Python,
- `./src` смонтирован read-only как `/opt/src`; который исполняет код и хранит переменные до остановки.
- `./data` смонтирован read-only как `/opt/data`;
- `PYTHONPATH=/opt/src`, поэтому helper-скрипты из `src/` доступны для импорта в ноутбуках; 1. Открой `http://localhost:8888` в отдельной вкладке браузера.
- первый ноутбук курса: `01_environment_and_smoke_test.ipynb`; 2. Дважды нажми на `01_environment_and_smoke_test.ipynb` в списке файлов слева.
- локальный учебный data bundle для Модуля 3 должен быть доступен внутри Jupyter по пути `/opt/data/nyc_taxi`. 3. Если появится выбор ядра, выбери **Python 3 (ipykernel)**. Изменить ядро можно
через **Kernel → Change Kernel…**. Оно работает внутри контейнера `jupyter`,
где уже есть зависимости курса. Дождись состояния **Idle** в нижней панели.
4. Выдели первую кодовую ячейку и запусти её через `Shift+Enter`, затем остальные
ячейки по порядку. Результат появится под ячейкой. Сохрани ноутбук через
**File → Save Notebook** (`Ctrl+S`; на macOS — `Cmd+S`).
Терминал JupyterLab тоже работает внутри контейнера. Команды `docker compose`
выполняй в терминале хоста, откуда запускал стенд.
### Как понять, что первый запуск прошел
В третьей кодовой ячейке первого ноутбука появится отчет, содержащий:
```text
Spark cluster smoke test passed.
master_url=spark://spark-master:7077
result_sum=499999500000
expected_sum=499999500000
```
В отчете есть и другие строки; значения выше должны совпасть. Затем выполни
самостоятельное задание: замени `TODO` в словаре `service_roles` ролями сервисов.
Сам факт запуска ячейки с `TODO` не означает, что задание выполнено.
В конце выполни ячейку `spark.stop()`, сохрани ноутбук и проверь, что его изменения
видны в `notebooks/01_environment_and_smoke_test.ipynb` на хосте. После повторного
открытия сохраненный вывод остается виден. Такой вывод — запись прошлого запуска;
он не означает, что ядро Python и Spark-сессия продолжают работать.
Правила запуска ячеек, перехода между модулями и сохранения данных собраны в
[памятке учащегося](./docs/study_workflow.md).
По желанию можно работать в [VS Code на `localhost:8889`](./docs/stack_reference.md#vs-code-в-браузере):
он запускается вместе со стендом. Некоторые сочетания браузера, расширений и
средств защиты мешают открытию ноутбуков в VS Code. В таком случае продолжи
курс в JupyterLab. Файлы общие, а ядра Python отдельные: перед сменой интерфейса
сохрани ноутбук, выполни `spark.stop()` и останови ядро. Не редактируй один файл
в двух интерфейсах одновременно.
## Базовая диагностика ## Базовая диагностика
### Посмотреть список контейнеров Начни с `docker compose ps -a` и логов проблемного сервиса, например:
```bash ```bash
docker compose ps docker compose logs --tail=100 jupyter
``` ```
### Посмотреть логи конкретного сервиса Выбери свой симптом в [таблице диагностики](./docs/study_workflow.md#если-что-то-не-работает).
При ошибке скачивания сразу открой [диагностику сети](./docs/network_access.md).
```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 ## Restart и reset
### Мягкий перезапуск стенда Для обычной паузы используй `docker compose stop`, на следующем занятии —
`docker compose up -d`. Порядок действий и последствия удаления контейнеров
описаны в [памятке](./docs/study_workflow.md#закончить-занятие).
```bash Полный сброс удаляет учебные таблицы и каталог. Его команда вынесена в
docker compose down [отдельный раздел](./docs/study_workflow.md#начать-с-чистых-данных), чтобы не путать
docker compose up -d с окончанием занятия.
```
### Полный reset с удалением данных
```bash
docker compose down -v
docker compose up -d
```
Используй полный reset, если хочешь пройти практику заново с чистого состояния.
## Подготовка учебного датасета (перед Модулем 3) ## Подготовка учебного датасета (перед Модулем 3)
@@ -156,6 +260,7 @@ docker compose up -d
Скачай каноническое учебное подмножество `NYC TLC Yellow Taxi Trip Records`: Скачай каноническое учебное подмножество `NYC TLC Yellow Taxi Trip Records`:
```bash ```bash
mkdir -p data/nyc_taxi
curl -fLo data/nyc_taxi/yellow_tripdata_2024-01.parquet \ curl -fLo data/nyc_taxi/yellow_tripdata_2024-01.parquet \
https://d37ci6vzurychx.cloudfront.net/trip-data/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 \ curl -fLo data/nyc_taxi/yellow_tripdata_2024-02.parquet \
@@ -181,10 +286,17 @@ ls -lh data/nyc_taxi/
Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца. Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца.
## Подключение DBeaver к Trino (перед Модулем 6)
- Зачем: в Модуле 6 можно работать с Trino через DBeaver параллельно с ноутбуком — привычный SQL-интерфейс.
- Предусловие: DBeaver установлен (ссылка на [dbeaver.io/download](https://dbeaver.io/download/)). Необязателен — ноутбук работает без DBeaver.
- Шаги: New Database Connection -> Trino. Host: `localhost`. Port: `8090`. Database/Catalog: `lakehouse`. Username: любая строка (напр. `student`). Password: пусто. Test Connection -> Finish.
- Проверка: `SHOW SCHEMAS FROM lakehouse;`. После модулей 4–5 должны быть видны `bronze` и `silver`; до их прохождения этих схем может еще не быть.
- Troubleshooting: стенд поднят? контейнер `trino` Up? порт 8090 свободен?
## Что делать дальше ## Что делать дальше
- пройти `notebooks/01_environment_and_smoke_test.ipynb`; После первого ноутбука открой [маршрут по модулям](./docs/course_program.md#маршрут-по-модулям).
- пройти `notebooks/02_lakehouse_mental_model.ipynb`; В нем есть ссылки на все восемь уроков и результат, который нужно получить
- подготовить data bundle по инструкции выше и пройти `notebooks/03_raw_ingest_and_first_read.ipynb`; перед переходом дальше. Для следующего занятия сохрани ссылку на
- свериться с программой курса в `docs/course_program.md`; [памятку учащегося](./docs/study_workflow.md).
- после прохождения первых модулей переходить к следующим учебным материалам.
+53 -5
View File
@@ -121,7 +121,8 @@ services:
- ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro - ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
minio: minio:
image: minio/minio:RELEASE.2024-10-02T17-50-41Z # Один и тот же образ опубликован автором в GHCR и Docker Hub.
image: &minio-image ${MINIO_REGISTRY:-ghcr.io}/coollabsio/minio:RELEASE.2025-04-22T22-12-26Z@sha256:a4938f37f1be1841b8e7b627ad0207b265345fd0d063e42d7410c78af0e63e68
container_name: minio container_name: minio
hostname: minio hostname: minio
networks: networks:
@@ -137,7 +138,7 @@ services:
- minio-data:/data - minio-data:/data
minio-init: minio-init:
image: minio/mc:RELEASE.2024-06-24T19-40-33Z-cpuv1 image: *minio-image
container_name: minio-init container_name: minio-init
hostname: minio-init hostname: minio-init
networks: networks:
@@ -145,13 +146,13 @@ services:
depends_on: depends_on:
- minio - minio
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -ec "
echo 'Waiting for MinIO...'; echo 'Waiting for MinIO...';
until mc alias set minio http://minio:9000 minioadmin minioadmin 2>/dev/null; do until mc alias set minio http://minio:9000 minioadmin minioadmin 2>/dev/null; do
sleep 1; sleep 1;
done; done;
echo 'MinIO is up, creating bucket...'; echo 'MinIO is up, creating bucket...';
mc mb -p minio/lakehouse || true; mc mb --ignore-existing minio/lakehouse;
echo 'Bucket lakehouse is ready'; echo 'Bucket lakehouse is ready';
exit 0; exit 0;
" "
@@ -160,6 +161,8 @@ services:
jupyter: jupyter:
build: build:
context: ./jupyter context: ./jupyter
additional_contexts:
spark-runtime: service:spark-master
args: args:
NB_UID: ${NB_UID:-1000} NB_UID: ${NB_UID:-1000}
NB_GID: ${NB_GID:-100} NB_GID: ${NB_GID:-100}
@@ -175,18 +178,62 @@ services:
- AWS_ACCESS_KEY_ID=minioadmin - AWS_ACCESS_KEY_ID=minioadmin
- AWS_SECRET_ACCESS_KEY=minioadmin - AWS_SECRET_ACCESS_KEY=minioadmin
- PYTHONPATH=/opt/src - PYTHONPATH=/opt/src
command:
- jupyter
- lab
- --ip=0.0.0.0
- --port=8888
- --no-browser
- --IdentityProvider.token=
- --PasswordIdentityProvider.hashed_password=
- --ServerApp.disable_check_xsrf=True
ports: ports:
- "8888:8888" - "127.0.0.1:8888:8888"
volumes: volumes:
- ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro - ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
- ./notebooks:/opt/work - ./notebooks:/opt/work
- ./src:/opt/src:ro - ./src:/opt/src:ro
- ./data:/opt/data:ro - ./data:/opt/data:ro
ide:
build:
context: ./ide
additional_contexts:
jupyter-runtime: service:jupyter
container_name: ide
hostname: ide
depends_on:
- spark-master
- minio
- iceberg-catalog-init
networks:
- spark-net
environment:
- AWS_ACCESS_KEY_ID=minioadmin
- AWS_SECRET_ACCESS_KEY=minioadmin
- PYTHONPATH=/opt/src
ports:
- "127.0.0.1:8889:8889"
volumes:
- ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
- ./notebooks:/opt/work
- ./src:/opt/src:ro
- ./data:/opt/data:ro
- .:/opt/course:ro
- ./notebooks:/opt/course/notebooks
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8889/healthz"]
interval: 10s
timeout: 5s
retries: 6
trino: trino:
image: trinodb/trino:478 image: trinodb/trino:478
container_name: trino container_name: trino
hostname: trino hostname: trino
# Heap JVM ограничен отдельно в trino/jvm.config; остальное — запас вне heap.
mem_limit: 2g
memswap_limit: 2g
networks: networks:
- spark-net - spark-net
depends_on: depends_on:
@@ -201,6 +248,7 @@ services:
- "8090:8080" # Web UI Trino - "8090:8080" # Web UI Trino
volumes: volumes:
- ./trino/catalog:/etc/trino/catalog:ro - ./trino/catalog:/etc/trino/catalog:ro
- ./trino/jvm.config:/etc/trino/jvm.config:ro
networks: networks:
spark-net: spark-net:
+45
View File
@@ -0,0 +1,45 @@
# Документация предметной области
Правила работы инженерных навыков с документацией предметной области при
изучении кодовой базы.
## Что прочитать перед изучением кодовой базы
- **`CONTEXT.md`** в корне репозитория
- **`CONTEXT-MAP.md`** в корне репозитория, если он существует: файл указывает
на отдельные `CONTEXT.md` для разных контекстов
- **`docs/adr/`**: ADR, относящиеся к области предстоящих изменений
Если какого-либо файла или каталога нет, продолжай работу без предупреждения.
Не отмечай его отсутствие и не предлагай создать его заранее. Навык
`/domain-modeling` создаёт такие документы по мере необходимости, когда
появляются новые термины или архитектурные решения.
## Структура файлов
Этот репозиторий использует схему `single-context`:
```text
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-example-decision.md
│ └── 0002-another-decision.md
└── src/
```
## Используй термины из глоссария
Когда в результате работы появляется понятие предметной области — например, в
названии задачи, предложении по рефакторингу, гипотезе или названии теста, —
используй термин из `CONTEXT.md`. Не заменяй его синонимами, которые глоссарий
явно исключает.
Если нужного понятия в глоссарии нет, проверь, действительно ли оно относится
к проекту. Если относится, зафиксируй пробел для последующей проработки через
`/domain-modeling`.
## Отмечай противоречия с ADR
Если предлагаемое изменение противоречит существующему ADR, явно укажи на
конфликт, а не отменяй ранее принятое решение молча.
+63
View File
@@ -0,0 +1,63 @@
# Трекер задач: Gitea
Задачи и спецификации этого репозитория хранятся в Gitea Issues:
`git.dementev.space/ddmitry/mini-lakehouse-lab`. Для работы с ними используй
CLI `tea`.
## Основные команды
- **Создать задачу**:
`tea issues create --title "..." --description "..."`
- **Прочитать задачу вместе с комментариями**:
`tea issues <номер> --comments --output yaml`
- **Получить список задач**:
`tea issues list --state open --output json`; при необходимости добавь
фильтры, например `--labels`
- **Добавить комментарий**:
`tea comments add <номер> --description "..."`
- **Добавить или удалить метки**:
`tea issues edit <номер> --add-labels "..."` или
`tea issues edit <номер> --remove-labels "..."`
- **Назначить исполнителя**:
`tea issues edit <номер> --add-assignees "<имя пользователя>"`
- **Закрыть задачу**: сначала опубликуй итоговый комментарий через
`tea comments add`, затем выполни `tea issues close <номер>`
Запускай команды из каталога репозитория: `tea` определит Gitea-подключение и
репозиторий по Git remote.
## Pull request как источник задач для триажа
**Обрабатывать pull request как входящие задачи: нет.**
Измени значение на `да`, если в будущем репозиторий начнёт рассматривать
внешние pull request как запросы на изменение. Навык `/triage` читает эту
настройку.
## Когда навык просит опубликовать задачу в трекере
Создай Gitea Issue с помощью `tea issues create`.
## Когда навык просит получить связанную задачу
Выполни `tea issues <номер> --comments --output yaml`.
## Операции Wayfinder
Навык `/wayfinder` использует одну задачу-карту и связанные с ней дочерние
задачи.
- **Карта**: задача с меткой `wayfinder:map`, содержащая разделы Notes,
Decisions-so-far и Fog
- **Дочерняя задача**: задача, описание которой начинается со строки
`Part of #<номер карты>` и содержит метку типа `wayfinder:<тип>`:
`research`, `prototype`, `grilling` или `task`
- **Блокировка**: строка `Blocked by: #<номер>, #<номер>` в начале описания;
задача разблокирована, когда закрыты все перечисленные задачи
- **Фронтир**: найди открытые дочерние задачи карты, исключи назначенные и
заблокированные, затем выбери первую по порядку в карте
- **Взять в работу**: назначь исполнителя командой
`tea issues edit <номер> --add-assignees "<имя пользователя>"`; это первая
запись в трекер в рамках сессии
- **Завершить**: опубликуй ответ в комментарии, закрой дочернюю задачу и добавь
в раздел Decisions-so-far карты ссылку на сохранённый контекст
+16
View File
@@ -0,0 +1,16 @@
# Метки триажа
Навыки используют пять стандартных ролей триажа. Таблица связывает эти роли с
метками трекера данного репозитория.
| Роль в mattpocock/skills | Метка в нашем трекере | Значение |
| ------------------------ | --------------------- | -------- |
| `needs-triage` | `needs-triage` | Сопровождающий должен оценить задачу |
| `needs-info` | `needs-info` | Для продолжения нужен ответ автора |
| `ready-for-agent` | `ready-for-agent` | Задача полностью описана и готова для автономного агента |
| `ready-for-human` | `ready-for-human` | Задачу должен выполнить человек |
| `wontfix` | `wontfix` | Задача не будет выполняться |
Когда навык упоминает роль, используй соответствующую метку из этой таблицы.
Если набор меток репозитория изменится, обнови правый столбец.
+7 -6
View File
@@ -50,7 +50,7 @@
1. Понимает различие между `storage`, `catalog` и `compute`, и может объяснить роль `MinIO`, `PostgreSQL`, `Spark` и `Trino` в этом стенде. 1. Понимает различие между `storage`, `catalog` и `compute`, и может объяснить роль `MinIO`, `PostgreSQL`, `Spark` и `Trino` в этом стенде.
2. Умеет поднять локальный стенд, проверить его базовую работоспособность и использовать логи/веб-интерфейсы для первичной диагностики проблем. 2. Умеет поднять локальный стенд, проверить его базовую работоспособность и использовать логи/веб-интерфейсы для первичной диагностики проблем.
3. Умеет загрузить raw-данные, прочитать их в `Spark`, проверить схему и подготовить данные к дальнейшей обработке. 3. Умеет настроить Spark-сессию для кластера, объяснить происхождение настроек и повторить подключение в новом ядре; загрузить raw-данные, прочитать их и проверить схему.
4. Умеет создать и заполнить `Iceberg`-таблицу как основной табличный формат курса. 4. Умеет создать и заполнить `Iceberg`-таблицу как основной табличный формат курса.
5. Умеет реализовать простой поток `raw -> bronze -> silver` с базовыми трансформациями и сохранением воспроизводимости шагов. 5. Умеет реализовать простой поток `raw -> bronze -> silver` с базовыми трансформациями и сохранением воспроизводимости шагов.
6. Умеет читать одну и ту же таблицу из `Spark` и `Trino` и понимает, почему это возможно без копирования данных. 6. Умеет читать одну и ту же таблицу из `Spark` и `Trino` и понимает, почему это возможно без копирования данных.
@@ -90,13 +90,13 @@
* один основной табличный формат: `Iceberg`; * один основной табличный формат: `Iceberg`;
* работа с raw-данными; * работа с raw-данными;
* построение слоев `bronze` и `silver`; * построение слоев `bronze` и `silver`;
* чтение и запись через `Spark`; * базовая настройка сессии, чтение и запись через `Spark`;
* чтение через `Trino`; * чтение через `Trino`;
* базовое schema evolution; * базовое schema evolution;
* snapshot/time travel на прикладном уровне; * snapshot/time travel на прикладном уровне;
* базовый `compaction/vacuum` с параллелями к `Greenplum AppendOnly`; * базовый `compaction/vacuum` с параллелями к `Greenplum AppendOnly`;
* набор практик в Jupyter Notebooks с обязательными заданиями и checkpoints; * набор практик в Jupyter Notebooks с обязательными заданиями и checkpoints;
* стартовая документация до первого ноутбука: prerequisites, запуск стенда, вход в Jupyter и базовый troubleshooting. * стартовая документация до первого ноутбука: prerequisites, запуск стенда, вход в JupyterLab и базовый troubleshooting.
Ориентировочная программа курса вынесена в отдельный документ: **[course_program.md](./course_program.md)**. Ориентировочная программа курса вынесена в отдельный документ: **[course_program.md](./course_program.md)**.
@@ -152,10 +152,10 @@
**Acceptance Criteria для учебных материалов и результата:** **Acceptance Criteria для учебных материалов и результата:**
1. В составе курса есть стартовая документация до первого ноутбука, включая prerequisites, запуск стенда, вход в `Jupyter` и получение учебного data bundle. 1. В составе курса есть стартовая документация до первого ноутбука, включая prerequisites, запуск стенда, вход в JupyterLab и получение учебного data bundle.
2. Студент проходит обязательные `checkpoints` по урокам. 2. Студент проходит обязательные `checkpoints` по урокам.
3. Студент самостоятельно выполняет набор практик на одном датасете, включая: 3. Студент самостоятельно выполняет набор практик на одном датасете, включая:
* загрузку raw-данных; * подключение к Spark и загрузку raw-данных;
* построение `bronze`; * построение `bronze`;
* построение `silver`; * построение `silver`;
* создание/чтение `Iceberg`-таблицы; * создание/чтение `Iceberg`-таблицы;
@@ -174,7 +174,8 @@
**В скоупе:** **В скоупе:**
* использование существующего `docker-compose.yml` (`Spark`, `Trino`, `MinIO`, `PostgreSQL`, `Jupyter`); * использование существующего `docker-compose.yml` (`Spark`, `Trino`, `MinIO`, `PostgreSQL`, `JupyterLab`, `code-server`);
* JupyterLab как основной интерфейс курса; браузерный VS Code доступен в том же стенде по желанию, с учётом совместимости браузера и средств защиты на компьютере;
* подготовка учебных датасетов и/или инструкций по их загрузке в репозиторий; * подготовка учебных датасетов и/или инструкций по их загрузке в репозиторий;
* наличие воспроизводимого способа получить учебный data bundle без ручного поиска по внешним сайтам; * наличие воспроизводимого способа получить учебный data bundle без ручного поиска по внешним сайтам;
* поддержка выполнения запросов к `Trino` из ноутбуков; * поддержка выполнения запросов к `Trino` из ноутбуков;
+56 -24
View File
@@ -1,6 +1,34 @@
# Программа учебного курса: Lakehouse без магии # Программа учебного курса: Lakehouse без магии
Этот документ заменяет предыдущий черновой план и выравнивает программу с PRD из [course_prd.md](./course_prd.md). Здесь собраны порядок модулей, ссылки на ноутбуки и результаты, по которым можно
проверить свою работу. Рамки курса и learning outcomes описаны в [course_prd.md](./course_prd.md).
## Маршрут по модулям
Перед первым уроком подготовь стенд по [START_HERE](../START_HERE.md).
Дальше проходи ноутбуки по порядку: файлы и таблицы из ранних модулей нужны в
следующих. Пауза между занятиями описана в [памятке учащегося](./study_workflow.md).
| Модуль и ноутбук | Что понадобится | Результат перед переходом дальше |
| --- | --- | --- |
| [1. Вход в стенд](../notebooks/01_environment_and_smoke_test.ipynb) | Работающий стенд | Smoke test вернул `499999500000`; ты можешь показать два worker-а и объяснить роли сервисов |
| [2. Модель Lakehouse](../notebooks/02_lakehouse_mental_model.ipynb) | Модуль 1 | Ты нашел файлы демо-таблицы в MinIO и запись каталога в PostgreSQL; выполнил очистку демо по ноутбуку |
| [3. Исходные данные](../notebooks/03_raw_ingest_and_first_read.ipynb) | [Четыре файла Taxi](../START_HERE.md#подготовка-учебного-датасета-перед-модулем-3) на хосте | Файлы загружены в raw-зону MinIO, Spark читает их; проверены схема и основные метрики |
| [4. Bronze и Iceberg](../notebooks/04_bronze_with_iceberg.ipynb) | Raw-файлы из Модуля 3 | Созданы bronze-таблица поездок и таблица зон из самостоятельного задания; видны их данные и metadata-файлы |
| [5. Silver](../notebooks/05_silver_layer.ipynb) | Обе bronze-таблицы | Создана silver-таблица, проверены правила очистки и качество результата |
| [6. Spark и Trino](../notebooks/06_spark_and_trino_on_same_table.ipynb) | Silver из Модуля 5; DBeaver по желанию | Одна таблица читается обоими движками; ты можешь объяснить, какие компоненты у них общие |
| [7. Изменения таблиц](../notebooks/07_safe_table_changes.ipynb) | Результаты предыдущих модулей | На демо-таблице проверены изменение схемы, чтение прошлого состояния и rollback; silver сохранена |
| [8. Обслуживание и финальная практика](../notebooks/08_maintenance_and_final_lab.ipynb) | Raw, bronze и silver | Проверено обслуживание таблиц; самостоятельно пройден поток от исходных файлов до чтения и проверок результата |
В каждом ноутбуке сначала прочитай объяснение, затем выполни демонстрацию и
самостоятельное задание. Checkpoint в конце урока — вопросы и проверки для
самопроверки. Сохраненный вывод помогает вернуться к результату, но не заменяет
собственный запуск и объяснение.
Основной интерфейс курса — JupyterLab. В том же стенде доступен
[VS Code в браузере](./stack_reference.md#vs-code-в-браузере), если тебе удобнее
работать в нём. Совместимость зависит от браузера, расширений и средств защиты
на компьютере; оба интерфейса открывают одни и те же ноутбуки.
## 1. Рамка курса ## 1. Рамка курса
@@ -27,7 +55,7 @@
## 3. Состав учебных материалов ## 3. Состав учебных материалов
Курс не должен состоять только из ноутбуков. Ноутбуки являются ядром практики, но вход в курс, запуск стенда и правила работы должны быть вынесены в обычную документацию, доступную до старта `Jupyter`. Курс не должен состоять только из ноутбуков. Ноутбуки являются ядром практики, но вход в курс, запуск стенда и правила работы должны быть вынесены в обычную документацию, доступную до открытия IDE.
### 3.1. Стартовая документация до первого ноутбука ### 3.1. Стартовая документация до первого ноутбука
@@ -38,15 +66,17 @@
* `START_HERE.md` или аналогичный стартовый документ с маршрутом прохождения курса; * `START_HERE.md` или аналогичный стартовый документ с маршрутом прохождения курса;
* инструкция по prerequisites: `Docker`, `Docker Compose`, свободные порты, базовые команды; * инструкция по prerequisites: `Docker`, `Docker Compose`, свободные порты, базовые команды;
* пошаговый гайд по запуску стенда: `build`, `up`, проверка сервисов, открытие UI; * пошаговый гайд по запуску стенда: `build`, `up`, проверка сервисов, открытие UI;
* краткое объяснение, как пользоваться `Jupyter`, где лежат ноутбуки и как читать структуру урока; * краткое объяснение, как запускать ноутбуки в JupyterLab, где лежат ноутбуки и как читать структуру урока;
* troubleshooting по типовым проблемам старта; * troubleshooting по типовым проблемам старта;
* инструкция по reset/restart стенда для повторного прохождения практик. * [памятка учащегося](./study_workflow.md): продолжение занятия, сохранение данных,
перезапуск ядра и диагностика;
* отдельная инструкция по сбросу учебных данных для повторного прохождения практик.
**Что должен закрывать этот слой:** **Что должен закрывать этот слой:**
* студент понимает, что нужно установить и проверить до начала курса; * студент понимает, что нужно установить и проверить до начала курса;
* студент может поднять стенд без ментора; * студент может поднять стенд без ментора;
* студент знает, как попасть в `Jupyter`, `MinIO`, `Trino UI`; * студент знает, как попасть в JupyterLab, `MinIO`, `Trino UI`;
* студент понимает, как устроен формат уроков и что от него ожидается. * студент понимает, как устроен формат уроков и что от него ожидается.
### 3.2. Практическое ядро курса ### 3.2. Практическое ядро курса
@@ -59,9 +89,9 @@
* вспомогательные скрипты в `src/spark` и `src/trino`; * вспомогательные скрипты в `src/spark` и `src/trino`;
* инструкции по загрузке или подготовке учебных датасетов; * инструкции по загрузке или подготовке учебных датасетов;
* краткий glossary по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`; * краткий [glossary](./glossary.md) по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`;
* cheat sheet по типовым командам, адресам сервисов, ключевым путям и точкам входа; * [шпаргалка](./stack_reference.md#краткая-шпаргалка) по типовым командам, адресам сервисов, ключевым путям и точкам входа (секция в `stack_reference.md`);
* опционально, отдельные mentor notes для ведения курса с ментором. * опционально, [заметки для ментора](./mentor_notes.md) для ведения курса с ментором.
## 4. Трассировка Learning Outcomes на модули ## 4. Трассировка Learning Outcomes на модули
@@ -69,7 +99,7 @@
| --- | --- | --- | | --- | --- | --- |
| `LO1`. Понимание различий между `storage`, `catalog`, `compute` | 1, 2, 6 | checkpoints модулей 2 и 6 | | `LO1`. Понимание различий между `storage`, `catalog`, `compute` | 1, 2, 6 | checkpoints модулей 2 и 6 |
| `LO2`. Умение поднять и диагностировать стенд | 1 | checkpoint модуля 1 | | `LO2`. Умение поднять и диагностировать стенд | 1 | checkpoint модуля 1 |
| `LO3`. Умение загрузить raw-данные и проверить схему | 3 | checkpoint модуля 3 | | `LO3`. Умение подключиться к Spark, загрузить raw-данные и проверить схему | 3 | checkpoint модуля 3 |
| `LO4`. Умение создать и заполнить `Iceberg`-таблицу | 4 | checkpoint модуля 4 | | `LO4`. Умение создать и заполнить `Iceberg`-таблицу | 4 | checkpoint модуля 4 |
| `LO5`. Умение собрать поток `raw -> bronze -> silver` | 4, 5, 8 | checkpoints модулей 5 и 8 | | `LO5`. Умение собрать поток `raw -> bronze -> silver` | 4, 5, 8 | checkpoints модулей 5 и 8 |
| `LO6`. Умение читать одну таблицу из `Spark` и `Trino` | 6, 8 | checkpoint модуля 6 и финальная практика | | `LO6`. Умение читать одну таблицу из `Spark` и `Trino` | 6, 8 | checkpoint модуля 6 и финальная практика |
@@ -85,10 +115,10 @@
**Содержание:** **Содержание:**
* запуск `docker compose build` и `docker compose up -d`; * скачивание готовых образов, сборка окружения и запуск по `START_HERE.md`;
* проверка сервисов и веб-интерфейсов; * проверка сервисов и веб-интерфейсов;
* базовый smoke test для Spark; * базовый smoke test для Spark;
* первый обзор того, где в стенде `MinIO`, `PostgreSQL`, `Spark`, `Trino`, `Jupyter`. * первый обзор того, где в стенде `MinIO`, `PostgreSQL`, `Spark`, `Trino` и IDE.
**Практика студента:** **Практика студента:**
@@ -124,25 +154,27 @@
**Рабочее название ноутбука:** `03_raw_ingest_and_first_read.ipynb` **Рабочее название ноутбука:** `03_raw_ingest_and_first_read.ipynb`
**Зачем нужен:** показать, как raw-данные попадают в стенд и как с ними безопасно начать работать. **Зачем нужен:** пройти путь от подключения к Spark до первого чтения и проверки исходных файлов Taxi.
**Ориентир по времени:** около 2 часов, из них 20–30 минут на подготовку Spark-сессии.
**Содержание:** **Содержание:**
* работа с заранее подготовленным локальным data bundle из onboarding-документации; * подключение к Spark-кластеру: сессия, контекст, driver и executors; настройки из файла, параметров запуска и кода;
* явная загрузка исходных `PARQUET`-файлов в raw-зону; * повторное использование сессии, остановка и перезапуск ядра; изменение настроек во время работы;
* чтение raw-данных через Spark; * подготовленный data bundle и загрузка исходных файлов в raw-зону MinIO;
* проверка схемы, типов и базового качества данных; * чтение через Spark, проверка схемы и типов;
* обсуждение того, почему raw лучше не "чинить на месте" и почему датасет не должен скачиваться "из интернета из ноутбука". * базовые метрики, аномалии и причины сохранять raw без изменений.
**Практика студента:** **Практика студента:**
* проверить состав локально полученного набора `NYC Taxi`; * заполнить шаблон сессии с подготовленным каталогом Iceberg; проверить подключение на собственной временной таблице и удалить ее;
* загрузить его в raw-зону `MinIO`; * изменить и восстановить часовой пояс, сравнить повторный вызов builder-а с новым запуском приложения;
* прочитать несколько месяцев `NYC Taxi`; * проверить состав data bundle и загрузить несколько месяцев Taxi в MinIO;
* проверить схему и посчитать базовые метрики; * прочитать поездки и справочник зон, проверить схему и посчитать метрики;
* найти простые аномалии или проблемные значения. * найти проблемные значения и объяснить, почему их сохраняют в raw.
**Checkpoint:** студент умеет загрузить учебный набор в raw-зону, прочитать raw-данные, проверить схему и объяснить, почему raw-слой нужен как воспроизводимая точка входа. **Checkpoint:** студент создает сессию для кластера, объясняет источники настроек и место выполнения заданий, умеет повторить подключение в новом ядре. Загружает учебный набор в raw-зону, читает его через Spark, проверяет схему и объясняет, зачем сохранять исходные данные.
### Модуль 4. Первая рабочая Iceberg-таблица и слой bronze ### Модуль 4. Первая рабочая Iceberg-таблица и слой bronze
@@ -256,7 +288,7 @@
1. Поднимает локальный стенд и диагностирует типовые проблемы на старте. 1. Поднимает локальный стенд и диагностирует типовые проблемы на старте.
2. Понимает различие между `storage`, `catalog` и `compute`. 2. Понимает различие между `storage`, `catalog` и `compute`.
3. Умеет загрузить raw-данные и прочитать их в Spark. 3. Подключается к Spark-кластеру, загружает raw-данные и проверяет их после чтения.
4. Умеет создать и заполнить Iceberg-таблицу. 4. Умеет создать и заполнить Iceberg-таблицу.
5. Умеет построить простой поток `raw -> bronze -> silver`. 5. Умеет построить простой поток `raw -> bronze -> silver`.
6. Умеет читать одну и ту же таблицу из `Spark` и `Trino`. 6. Умеет читать одну и ту же таблицу из `Spark` и `Trino`.
+153
View File
@@ -0,0 +1,153 @@
# Глоссарий курса
Краткий справочник терминов, сгруппированных по темам. Если нужен полный технический 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.
## Spark-сессия и выполнение
### SparkSession
Точка входа в DataFrame и Spark SQL: объект `spark` в ноутбуке. Builder задаёт параметры, а `getOrCreate()` создаёт сессию или возвращает уже работающую.
### SparkContext
Связывает Spark-приложение с вычислительными ресурсами. Через `spark.sparkContext` можно проверить фактические `master`, `appName` и `applicationId`. Повторный builder не заменяет работающий контекст.
### Driver и executors
Driver управляет приложением: выполняет код ноутбука, строит план и отправляет задания. Executors выполняют задания и читают данные. В нашем стенде driver работает в `jupyter`, executors запускаются на Spark worker-ах. Master выделяет приложению ресурсы worker-ов.
**Где в курсе:** Модуль 3.
## Структура 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-ов и файлов, которые больше не нужны ни одному сохранённому snapshot-у. Может освободить место, но делает невозможным time travel к удалённым snapshot-ам. Необратимая операция.
**В этом курсе:** `CALL lakehouse.system.expire_snapshots(table => '...', older_than => TIMESTAMP '<cutoff>', retain_last => N)`. `older_than` задаёт границу времени, а `retain_last` защищает последние N snapshot-ов в цепочке предков текущего состояния независимо от возраста. Без явной границы при стандартных настройках удаляются только snapshot-ы старше 5 дней; один `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 (финальная практика).
+7 -2
View File
@@ -17,10 +17,11 @@
| Зона | Где лежит | Назначение | | Зона | Где лежит | Назначение |
| --- | --- | --- | | --- | --- | --- |
| Runtime / stand | `docker-compose.yml`, `spark/`, `trino/`, `jupyter/` | Топология сервисов, образы, конфиги, порты | | Runtime / stand | `docker-compose.yml`, `spark/`, `trino/`, `jupyter/`, `ide/` | Топология сервисов, образы, конфиги, порты |
| Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут | | Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут |
| Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты | | Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты |
| Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей | | Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей |
| Reference materials | `docs/glossary.md`, `docs/mentor_notes.md` | Справочник терминов и заметки для ментора |
| Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика | | Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика |
| Internal planning | `plans/` | Внутренние living docs по разработке материалов | | Internal planning | `plans/` | Внутренние living docs по разработке материалов |
| Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут | | Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут |
@@ -30,9 +31,10 @@
| Файл или каталог | За что отвечает | Что не стоит туда складывать | | Файл или каталог | За что отвечает | Что не стоит туда складывать |
| --- | --- | --- | | --- | --- | --- |
| `docker-compose.yml` | Реальный состав сервисов, контейнеров, сетей, портов и зависимостей | Учебные пояснения, которые не нужны для запуска | | `docker-compose.yml` | Реальный состав сервисов, контейнеров, сетей, портов и зависимостей | Учебные пояснения, которые не нужны для запуска |
| `spark/`, `trino/`, `jupyter/` | Конкретные runtime-конфиги и образы | Описание программы курса | | `spark/`, `trino/`, `jupyter/`, `ide/` | Конкретные runtime-конфиги и образы | Описание программы курса |
| `README.md` | Верхнеуровневое объяснение репозитория, архитектуры и точек входа | Пошаговый student onboarding во всех деталях и low-level runtime reference | | `README.md` | Верхнеуровневое объяснение репозитория, архитектуры и точек входа | Пошаговый student onboarding во всех деталях и low-level runtime reference |
| `START_HERE.md` | Первый маршрут студента: prerequisites, запуск, первые UI, первый ноутбук, базовая диагностика | Полный PRD курса или подробный бэклог модулей | | `START_HERE.md` | Первый маршрут студента: prerequisites, запуск, первые UI, первый ноутбук, базовая диагностика | Полный PRD курса или подробный бэклог модулей |
| `docs/study_workflow.md` | Повседневный маршрут: продолжить и закончить занятие, сохранить результат, восстановиться после ошибки | Состав модулей, установка окружения с нуля |
| `docs/stack_reference.md` | Технический reference стенда: сервисы, порты, доступ, reset, smoke-тесты | Роль основного onboarding-документа или описание всей программы курса | | `docs/stack_reference.md` | Технический reference стенда: сервисы, порты, доступ, reset, smoke-тесты | Роль основного onboarding-документа или описание всей программы курса |
| `docs/course_prd.md` | Product scope, аудитория, learning outcomes, dataset strategy, out of scope | Технические мелочи запуска контейнеров | | `docs/course_prd.md` | Product scope, аудитория, learning outcomes, dataset strategy, out of scope | Технические мелочи запуска контейнеров |
| `docs/course_program.md` | Модульная структура курса, состав материалов, checkpoints | Подробные docker-команды и legacy-лабы | | `docs/course_program.md` | Модульная структура курса, состав материалов, checkpoints | Подробные docker-команды и legacy-лабы |
@@ -41,6 +43,8 @@
| `plans/` | Внутренний статус работ, живые планы, декомпозиция задач | Источник истины для student-facing маршрута | | `plans/` | Внутренний статус работ, живые планы, декомпозиция задач | Источник истины для student-facing маршрута |
| `docs/archive/` | Справочный legacy-контент | Актуальные инструкции без явной пометки `legacy` | | `docs/archive/` | Справочный legacy-контент | Актуальные инструкции без явной пометки `legacy` |
Отзывы студентов и порядок следующих доработок собраны в [плане развития курса](../plans/course-roadmap.md). Подробные задания и текущий статус исполнения ведутся в [Gitea Issues](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues); правила работы с трекером описаны в [issue-tracker.md](./agents/issue-tracker.md).
## Порядок приоритетов, если документы расходятся ## Порядок приоритетов, если документы расходятся
- Для реального поведения стенда: `docker-compose.yml` и runtime-конфиги важнее `README.md`. - Для реального поведения стенда: `docker-compose.yml` и runtime-конфиги важнее `README.md`.
@@ -72,6 +76,7 @@
- `START_HERE.md`; - `START_HERE.md`;
- `docs/course_program.md`; - `docs/course_program.md`;
- `docs/study_workflow.md`;
- соответствующие `notebooks/`; - соответствующие `notebooks/`;
- `README.md`, если меняется верхнеуровневый вход в репозиторий. - `README.md`, если меняется верхнеуровневый вход в репозиторий.
+109
View File
@@ -0,0 +1,109 @@
# Заметки для ментора
Этот документ — для ведения курса с ментором. Студенту он не нужен.
## Формат работы
Менти работает преимущественно самостоятельно. Ноутбуки написаны так, чтобы студент мог пройти демо-часть и самостоятельные задания без посторонней помощи.
Роль ментора:
- **задать направление** — обозначить, на что обратить внимание в модуле, какие параллели с текущим опытом студента искать;
- **отвечать на вопросы** — по ходу прохождения или на checkpoint-е;
- **не вести за руку** — не объяснять материал до того, как студент попробовал сам.
Типичный цикл: ментор даёт задание на модуль → менти проходит самостоятельно → встреча для обсуждения вопросов и checkpoint-а.
## Ориентировочный тайминг
| Модуль | Тема | Самостоятельная работа | Обсуждение с ментором | Комментарий |
| --- | --- | --- | --- | --- |
| 1 | Вход в стенд | 30-40 мин | 10-15 мин | Без учета первой сборки: ее время зависит от сети и компьютера |
| 2 | Ментальная модель | 40-60 мин | 15-20 мин | Ключевой модуль; убедись, что студент заходил в MinIO |
| 3 | Raw-данные | Около 2 часов | 10 мин | Включая 20–30 минут на практику со Spark-сессией; датасет скачивается заранее |
| 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 не хватает ресурсов.** Сверься с [системными требованиями](../START_HERE.md#системные-требования). Проверь отдельно память компьютера и лимит памяти Docker Desktop/WSL2: контейнерам может быть доступна лишь часть RAM хоста.
- **После паузы виден старый вывод, но переменные пропали.** Разбери разницу между файлом ноутбука и ядром Python по [памятке учащегося](./study_workflow.md). Передай студенту ее до второго занятия; для обращения за помощью там есть список нужных сведений.
- **Порты заняты.** Проверь 8080, 8888, 9000 и остальные [порты стенда](../START_HERE.md#порты-стенда). `docker compose ps -a` покажет, какой сервис не стартовал. Решение: остановить конфликтующий процесс или (крайний вариант) поменять порт в `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 переключает текущее состояние на существующий snapshot-предок и не создаёт новый snapshot. `CREATE OR REPLACE` создаёт новый snapshot без родителя и заменяет текущие данные, схему и партиционирование; прежние snapshot-ы сохраняются для time travel, но перестают быть предками текущего состояния. Это описано в Секциях 8 и 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 нельзя пропускать** даже для опытных студентов. Они содержат ключевые практики, которые отличают «знаю теорию» от «умею делать руками».
+74
View File
@@ -0,0 +1,74 @@
# Если образы или зависимости не скачиваются
Доступность репозиториев зависит от сети и провайдера, в том числе в РФ.
GHCR выбран основным источником MinIO, чтобы его загрузка не зависела от Docker
Hub. Это не гарантия доступности без VPN у каждого провайдера: у стенда остаются
другие внешние зависимости. Для первой сборки и скачивания датасета нужен интернет.
## Сначала найди источник ошибки
Запускай команды в терминале хоста, из корня репозитория:
```bash
docker compose pull minio minio-init postgres trino
docker compose --progress plain build
```
Если первая команда завершилась с ошибкой, сначала реши проблему загрузки образа.
Если ошибка возникла во второй, найди имя сервиса и адрес, с которого скачивался
файл. Открывающийся в браузере сайт ещё не подтверждает доступ из Docker.
| Где возникла ошибка | Что скачивается | Что проверить |
| --- | --- | --- |
| `ghcr.io` при загрузке `minio` | Готовые MinIO и `mc` | Резервный источник ниже |
| `docker.io`, `registry-1.docker.io`, `auth.docker.io` | PostgreSQL, Trino, базовый Spark; резерв MinIO | Доступ Docker к Docker Hub и настройки прокси |
| Ubuntu archive, PyPI, Maven Central при сборке `spark` или `jupyter` | Системные пакеты, Python-пакеты и JAR | Доступ к зависимостям из сборки |
| GitHub Releases, Open VSX при сборке `ide` | code-server и расширения | Доступ к зависимостям из сборки |
## Резервный источник MinIO
Автор образа публикует его в двух местах: GHCR и Docker Hub. В обоих закреплён
один digest, поэтому смена registry не меняет версию или содержимое образа.
Ссылка на сборку и версия приведены в [техническом reference](./stack_reference.md#minio).
Если GHCR недоступен, создай файл `.env` в корне репозитория и добавь строку:
```dotenv
MINIO_REGISTRY=docker.io
```
Если файл уже существует, измени только эту настройку. Другие значения, например
`NB_UID` и `NB_GID`, сохрани. Затем повтори:
```bash
docker compose pull minio minio-init
```
Дальше используй обычные команды из [START_HERE](../START_HERE.md#быстрый-запуск).
Для возврата к основному источнику установи `MINIO_REGISTRY=ghcr.io`.
Оба источника публичные; регистрация для скачивания MinIO не требуется.
`.env.example` содержит пример настроек, а `.env` исключён из Git.
## Если недоступны другие источники
Переключение MinIO не меняет источник PostgreSQL, Trino, Spark или зависимостей
сборки. Если в твоей сети нужен VPN или HTTP-прокси, важно, чтобы им мог
пользоваться Docker, а не только браузер.
- **Docker Desktop:** настрой прокси в разделе **Settings → Resources → Proxies**.
См. [официальную инструкцию](https://docs.docker.com/desktop/settings-and-maintenance/settings/#proxies).
- **Docker Engine на Linux:** скачиванием образов занимается демон Docker.
Для него есть [отдельная настройка прокси](https://docs.docker.com/engine/daemon/proxy/).
- **Команды внутри сборки:** `apt`, `pip`, `curl` и установка расширений требуют
доступа из build-контейнера. Docker описывает
[настройки прокси для сборок](https://docs.docker.com/engine/cli/proxy/#build-with-a-proxy-configuration)
отдельно от настроек демона. Прокси на `localhost` хоста не обязательно
доступен как `localhost` внутри контейнера.
Ошибка `429` обычно означает лимит запросов, а `401`/`403` может быть связана
с доступом к registry. Сам по себе этот код не доказывает блокировку по стране.
Не меняй версии образов и не удаляй данные стенда для исправления сетевой ошибки.
Если нужна помощь, передай ментору ОС, вывод `docker compose version`, команду,
имя сервиса и фрагмент ошибки с адресом источника. Пароли и адреса прокси
с учётными данными в сообщение не добавляй.
+283 -22
View File
@@ -15,20 +15,27 @@
| MinIO API | `minio` | `9000` | S3-compatible endpoint | | MinIO API | `minio` | `9000` | S3-compatible endpoint |
| MinIO Console | `minio` | `9001` | веб-консоль бакетов и объектов | | MinIO Console | `minio` | `9001` | веб-консоль бакетов и объектов |
| PostgreSQL | `postgres-iceberg` | `5432` | JDBC-каталог Iceberg | | PostgreSQL | `postgres-iceberg` | `5432` | JDBC-каталог Iceberg |
| JupyterLab | `jupyter` | `8888` | практические ноутбуки | | JupyterLab | `jupyter` | `8888` | основной интерфейс для ноутбуков и терминала |
| VS Code в браузере (code-server) | `ide` | `8889` | альтернативный интерфейс для тех же ноутбуков |
## Основные команды ## Основные команды
Все команды выполняются из корня репозитория. Все команды выполняются в терминале хоста из корня репозитория.
Для первого запуска и настройки прав используй [START_HERE](../START_HERE.md),
для продолжения работы — [памятку учащегося](./study_workflow.md).
### Сборка и запуск ### Сборка и запуск
```bash ```bash
docker compose pull minio minio-init postgres trino
docker compose build docker compose build
docker compose up -d docker compose up -d
docker compose ps docker compose ps -a
``` ```
JupyterLab и VS Code собираются и запускаются вместе; выбирать интерфейс
можно после старта стенда.
### Логи ### Логи
```bash ```bash
@@ -36,16 +43,50 @@ docker compose logs -f spark-master
docker compose logs -f trino docker compose logs -f trino
docker compose logs -f minio docker compose logs -f minio
docker compose logs -f jupyter docker compose logs -f jupyter
docker compose logs -f ide
``` ```
### Остановка и reset ### Остановка и reset
```bash | Действие | Команда | Что происходит с данными |
docker compose down | --- | --- | --- |
docker compose down -v | Пауза до следующего занятия | `docker compose stop` | Контейнеры остановлены, файлы и volumes сохранены |
``` | Продолжение | `docker compose up -d` | Сервисы запускаются с прежними данными |
| Удаление контейнеров и сети | `docker compose down` | Volumes и файлы на хосте сохраняются, состояние внутри удаленных контейнеров теряется |
| Полный сброс учебных данных | См. [отдельную инструкцию](./study_workflow.md#начать-с-чистых-данных) | Удаляются MinIO volume и каталог PostgreSQL |
`down -v` удаляет volumes и возвращает стенд в чистое состояние. Переменные Python и живые Spark-сессии после остановки нужно создать заново.
## Ресурсы и проверенная среда
Актуальный бюджет для учащегося приведен в
[системных требованиях](../START_HERE.md#системные-требования).
Он учитывает одновременную работу двух Spark worker-ов, драйвера в Jupyter, Trino,
MinIO и PostgreSQL. У каждого worker-а в Compose указаны 2 CPU и 2 ГБ для
приложений Spark; это не предел потребления всей JVM или контейнера.
Потребление при простое не показывает пик во время урока.
Проверка 19 октября 2026 года: Linux x86_64, Docker Engine, 6 CPU и около
11,1 ГиБ памяти, видимых Docker. Выполнены первый ноутбук в Chromium и проверка
Spark → Iceberg → Trino. Затем Trino проверен с лимитом контейнера 2 ГиБ и heap
1 ГиБ на трех месяцах Taxi: 9 554 778 строк bronze, 9 418 184 строк silver
и 265 зон. Запросы Модуля 6 и дополнительный JOIN со справочником прошли;
агрегаты и JOIN сверены со Spark. Пик памяти контейнера Trino по cgroup составил
около 1,25 ГиБ, событий OOM и перезапусков не было.
Наблюдавшийся суммарный пик памяти контейнеров без неактивного файлового кэша
составил около 5,44 ГиБ. Это измерение выбранного сценария, а не проверка всей
среды под общим лимитом 8 ГБ: память Docker daemon, ядра и других процессов
Linux/WSL2 в сумму не входит. Прогона всех восьми модулей под таким лимитом
не было. Docker Desktop и ARM64 в этой проверке не запускались.
Методика и результаты сохранены в [отчете проверки](../plans/trino-memory-validation.md).
Размеры локально собранных образов на этой машине: Spark — около 3,37 ГБ,
Jupyter — 3,72 ГБ, IDE — 4,90 ГБ. Jupyter включает слои Spark, IDE включает слои
Jupyter: складывать эти три числа для оценки места неправильно. IDE добавляет
около 1,18 ГБ. IDE вместе с отдельными образами Trino, PostgreSQL и MinIO дает
около 8,1 ГБ до учета кэша сборки, данных и особенностей хранилища Docker.
Поэтому перед первой сборкой нужен запас места сверх размера готовых образов.
## Доступ к сервисам ## Доступ к сервисам
@@ -53,18 +94,52 @@ docker compose down -v
- Master UI: `http://localhost:8080` - Master UI: `http://localhost:8080`
- Worker UI: `http://localhost:8081` и `http://localhost:8082` - Worker UI: `http://localhost:8081` и `http://localhost:8082`
- Spark master endpoint внутри сети compose: `spark://spark-master:7077` - Адрес Spark master внутри сети Docker Compose: `spark://spark-master:7077`
Адрес кластера (`spark.master`) и настройки доступа к Iceberg и MinIO заданы в
[`spark/spark-defaults.conf`](../spark/spark-defaults.conf). Docker подключает этот
файл к контейнеру Jupyter как `/opt/spark/conf/spark-defaults.conf`.
При первом вызове `getOrCreate()` Spark читает настройки и создает сессию.
Повторный вызов использует уже работающую сессию. Если изменил адрес кластера
или настройки в файле, перезапусти ядро ноутбука и снова выполни ячейки
с импортами и созданием сессии.
Фактический master и имя приложения доступны через `spark.sparkContext.master`
и `.appName`. Текущие SQL-настройки можно прочитать через `spark.conf.get(...)`.
В Spark 3.5.1 `spark.sql.session.timeZone` меняется через `spark.conf.set(...)`
без остановки сессии. Для нового имени приложения нужен новый SparkContext.
Пример запуска smoke-скрипта: Пример запуска smoke-скрипта:
```bash ```bash
docker compose exec spark-master \ docker compose exec jupyter python3 /opt/src/spark/cluster_smoke.py
/opt/spark/bin/spark-submit /opt/src/spark/cluster_smoke.py
``` ```
### Trino ### Trino
- Web UI: `http://localhost:8090` - Web UI: `http://localhost:8090`
- Контейнер ограничен 2 ГиБ RAM (`mem_limit: 2g`). `memswap_limit: 2g`
отключает swap для него: одинаковое значение задает общий предел RAM + swap.
- В [`trino/jvm.config`](../trino/jvm.config) heap Java начинается с 512 МиБ
и может вырасти до 1 ГиБ (`-Xms512M`, `-Xmx1G`). Остальная память контейнера
оставлена для потоков, служебных структур JVM и буферов вне heap.
Эти ограничения относятся к Trino; у всего Compose-проекта общего лимита
памяти нет. Если контейнер достигнет жесткого предела, он может быть завершен
системой. Увеличение только heap без изменения бюджета контейнера уменьшает
запас для памяти вне heap. См. [лимиты Compose](https://docs.docker.com/reference/compose-file/services/#mem_limit)
и [настройку JVM Trino](https://trino.io/docs/current/installation/deployment.html#jvm-config).
После обновления этих файлов примени настройки из терминала хоста:
```bash
docker compose up -d --no-deps --force-recreate trino
docker compose ps -a trino
```
Дождись `healthy`. Эта операция пересоздает только Trino и применяет в том числе
изменения содержимого `jvm.config`; таблицы остаются в MinIO и PostgreSQL.
CLI внутри контейнера: CLI внутри контейнера:
@@ -80,6 +155,11 @@ SHOW SCHEMAS FROM lakehouse;
SHOW TABLES FROM lakehouse.default; SHOW TABLES FROM lakehouse.default;
``` ```
**Подключение через DBeaver:**
- Host: `localhost`, Port: `8090`, Catalog: `lakehouse`, User: любая строка, Password: нет.
- Driver: Trino (встроен в DBeaver).
- Проверка: `SHOW SCHEMAS FROM lakehouse;`.
### MinIO ### MinIO
- S3 endpoint: `http://localhost:9000` - S3 endpoint: `http://localhost:9000`
@@ -87,6 +167,21 @@ SHOW TABLES FROM lakehouse.default;
- логин: `minioadmin` - логин: `minioadmin`
- пароль: `minioadmin` - пароль: `minioadmin`
Стенд использует готовый образ `coollabsio/minio:RELEASE.2025-04-22T22-12-26Z`.
Это сторонняя [сборка из официальных исходников MinIO](https://github.com/coollabsio/minio),
которая содержит сервер и клиент `mc` версии `RELEASE.2025-08-13T08-35-41Z`.
Версия сервера предшествует [изменению встроенной консоли в мае 2025 года](https://github.com/minio/minio/releases/tag/RELEASE.2025-05-24T17-08-30Z).
Образ подходит для локального курса; это историческая
версия без текущих обновлений, не production-конфигурация.
В Compose закреплён digest индекса для AMD64 и ARM64:
`sha256:a4938f37f1be1841b8e7b627ad0207b265345fd0d063e42d7410c78af0e63e68`.
Сервисы `minio` и `minio-init` используют один образ. Компиляция MinIO и установка
Go не нужны. По умолчанию образ скачивается из GHCR без авторизации. Автор
публикует тот же digest в Docker Hub; резервный источник выбирается через
`MINIO_REGISTRY=docker.io` в `.env`. Подробности — в
[инструкции по доступу к репозиториям](./network_access.md).
Бакет `lakehouse` обычно создаётся автоматически контейнером `minio-init`. Бакет `lakehouse` обычно создаётся автоматически контейнером `minio-init`.
### PostgreSQL ### PostgreSQL
@@ -107,15 +202,85 @@ docker compose run --rm iceberg-catalog-init
### JupyterLab ### JupyterLab
- адрес: `http://localhost:8888` Основной интерфейс курса: `http://localhost:8888`. Открой ноутбук из списка
- `./notebooks` смонтирован как `/opt/work` файлов слева; ядро **Python 3 (ipykernel)** выполняется внутри `jupyter`.
- `./src` смонтирован read-only как `/opt/src` Локальный Python или venv не нужен.
- `./data` смонтирован read-only как `/opt/data`
- `PYTHONPATH=/opt/src`
Первый ноутбук курса: `notebooks/01_environment_and_smoke_test.ipynb`. - Порт опубликован только на `127.0.0.1` хоста.
- Аутентификация и XSRF-проверка отключены для локального однопользовательского стенда.
- `./notebooks` смонтирован как `/opt/work` для чтения и записи.
- `./src` и `./data` доступны только для чтения как `/opt/src` и `/opt/data`.
- `PYTHONPATH=/opt/src`.
Для Модуля 3 локально скачанный data bundle должен лежать на хосте в `./data/nyc_taxi` и будет доступен внутри Jupyter по пути `/opt/data/nyc_taxi`. Для готового стенда на удалённой машине открой туннель в терминале своего компьютера:
```bash
ssh -N -L 8888:127.0.0.1:8888 user@host
```
Подставь свой SSH-адрес вместо `user@host`, оставь терминал открытым и перейди
на `http://localhost:8888`. Первый ноутбук:
`notebooks/01_environment_and_smoke_test.ipynb`. Перед Модулем 3 скачанный
на хост data bundle должен лежать в `./data/nyc_taxi`; в контейнере он доступен
как `/opt/data/nyc_taxi`.
### VS Code в браузере
Это альтернативный интерфейс, который запускается вместе с JupyterLab.
Используй его по желанию; основной маршрут курса описан для JupyterLab.
Открой `http://localhost:8889`. Сервис `ide` запускает code-server с установленными
расширениями Python и Jupyter. Python, ipykernel, Java, PySpark и зависимости курса
уже находятся в образе. Локальный Python или venv не нужен.
В рабочей области доступны ноутбуки, примеры из `src/` и документация. Репозиторий
смонтирован в `/opt/course` только для чтения, а его каталог `notebooks/` — для
чтения и записи. Так относительные ссылки между документами и ноутбуками работают
и внутри IDE. Пути `/opt/work`, `/opt/src`, `/opt/data` также доступны примерам курса.
Если сверху виден **Restricted Mode**, нажми **Manage → Trust** и закрой окно
Workspace Trust. Подтверди доверие только к своей копии учебного репозитория.
В папке **Ноутбуки** открой `01_environment_and_smoke_test.ipynb`, нажми
**Select Kernel → Python Environments** и выбери `/usr/bin/python3`. Это Python
внутри контейнера IDE. Расширение Jupyter запускает ядро там же; адрес отдельного
Jupyter Server вводить не требуется. Запускай ячейки через `Shift+Enter`, сохраняй
через **File → Save** (`Ctrl+S`; на macOS — `Cmd+S`). Терминал IDE тоже работает
внутри контейнера; команды `docker compose` выполняй на хосте стенда.
Совместимость зависит от браузера, его расширений и средств защиты на компьютере.
При проверке с Windows, Edge и Kaspersky сам редактор открывался, а ноутбук
блокировался дополнительной политикой безопасности страницы. Похожий случай
описан в [обсуждении code-server](https://github.com/coder/code-server/discussions/7457).
Если видишь `Error loading webview` или `Could not register service worker`
даже на `localhost`, продолжи в JupyterLab. Настраивать или отключать защиту
ради прохождения курса не требуется. Открытие окна VS Code само по себе
не подтверждает работу ноутбуков: проверь запуск ячейки и её вывод.
Порт IDE опубликован только на `127.0.0.1`, без пароля, для одного пользователя.
Открывай именно `localhost` или `127.0.0.1`: браузер считает их доверенным
контекстом даже по HTTP. Notebook webview использует service worker; при обращении
по обычному HTTP к IP сервера или имени в локальной сети этот механизм может
быть недоступен. Это ограничение secure context, отдельное от CORS и XSRF.
См. [secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Secure_Contexts)
и [webviews в code-server](https://coder.com/docs/code-server/FAQ#why-do-web-views-not-work).
Для стенда на удалённой машине используй SSH-туннель:
```bash
ssh -N -L 8889:127.0.0.1:8889 user@host
```
Затем открой `http://localhost:8889` на своём компьютере. Прямой доступ по
публичному адресу не входит в эту конфигурацию.
Образы собираются в порядке `spark-master → jupyter → ide` через Compose
`additional_contexts` (нужен Docker Compose 2.17+ и BuildKit). Версии code-server
и расширений зафиксированы в `ide/Dockerfile`; расширения скачиваются при сборке.
Автоматическое обновление расширений отключено, чтобы повторный запуск использовал
проверенные версии.
Файлы ноутбуков общие с JupyterLab, а ядра и переменные Python отдельные.
Перед сменой интерфейса сохрани ноутбук, выполни `spark.stop()` и останови ядро.
Не редактируй один файл в двух интерфейсах одновременно.
## Как связаны Spark, Trino, PostgreSQL и MinIO ## Как связаны Spark, Trino, PostgreSQL и MinIO
@@ -131,18 +296,47 @@ docker compose run --rm iceberg-catalog-init
| Файл | Что задаёт | | Файл | Что задаёт |
| --- | --- | | --- | --- |
| `docker-compose.yml` | состав сервисов, порты, volumes, init-контейнеры | | `docker-compose.yml` | состав сервисов, порты, volumes, init-контейнеры |
| `spark/spark-defaults.conf` | Spark catalog `lakehouse`, `s3a`, Iceberg extensions | | `spark/spark-defaults.conf` | Spark master, catalog `lakehouse`, `s3a`, Iceberg extensions |
| `trino/catalog/lakehouse.properties` | каталог Trino `lakehouse`, JDBC и S3-доступ | | `trino/catalog/lakehouse.properties` | каталог Trino `lakehouse`, JDBC и S3-доступ |
| `trino/jvm.config` | JVM Trino: heap до 1 ГиБ внутри контейнера с лимитом 2 ГиБ |
| `spark/Dockerfile` | образ Spark с Iceberg, S3A и Python-зависимостями | | `spark/Dockerfile` | образ Spark с Iceberg, S3A и Python-зависимостями |
| `jupyter/Dockerfile` | образ JupyterLab на базе Spark | | `jupyter/Dockerfile` | образ JupyterLab на базе Spark |
| `ide/Dockerfile` | code-server и расширения на базе образа Jupyter |
| `ide/lakehouse.code-workspace` | папки рабочей области и настройки Python |
| `docs/network_access.md` | источники образов и зависимостей, резервный registry и диагностика сети |
| `docs/study_workflow.md` | повседневная работа, сохранение состояния и диагностика ошибок |
## Smoke-тесты ## Smoke-тесты
### JupyterLab и первый ноутбук
Пройди [первый запуск ноутбука](../START_HERE.md#как-работать-с-ноутбуками):
открой Модуль 1, выбери **Python 3 (ipykernel)**, выполни ячейки и сверь вывод
Spark. После сохранения и повторного открытия вывод должен остаться в файле.
### Дополнительная IDE и Jupyter-ноутбук
На Linux с Docker Engine, после запуска стенда:
```bash
bash tests/browser_ide.sh
```
Проверка запускает Chromium в отдельном контейнере и проходит путь студента:
доверие рабочей области → открытие копии Модуля 1 → выбор Python → Run All →
вывод Spark в webview → сохранение → повторное открытие. Затем проверяет, что
все кодовые ячейки выполнились без ошибок и результат сохранился. Исходный
ноутбук не меняется. Скриншот остаётся в каталоге `/tmp/lakehouse-browser-check.*`,
путь печатается перед запуском. Docker Desktop этим скриптом не проверяется:
он использует Linux host networking.
Для проверки отдельного стенда можно задать `COMPOSE_FILE` и `IDE_URL`; по
умолчанию тест использует текущий Compose-проект и `http://localhost:8889`.
### 1. Проверка, что Spark-кластер жив ### 1. Проверка, что Spark-кластер жив
```bash ```bash
docker compose exec spark-master \ docker compose exec jupyter python3 /opt/src/spark/cluster_smoke.py
/opt/spark/bin/spark-submit /opt/src/spark/cluster_smoke.py
``` ```
### 2. Проверка Spark -> Trino через общий каталог ### 2. Проверка Spark -> Trino через общий каталог
@@ -167,9 +361,76 @@ docker compose exec spark-master \
/opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke" /opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke"
``` ```
## Краткая шпаргалка
### Ключевые S3-пути курса
| Путь | Назначение |
| --- | --- |
| `s3a://lakehouse/raw/nyc_taxi/` | raw-зона: исходные Parquet и CSV |
| `s3a://lakehouse/warehouse/bronze/` | bronze-таблицы Iceberg |
| `s3a://lakehouse/warehouse/silver/` | silver-таблицы Iceberg |
### Основные таблицы курса
| Таблица | Создаётся в |
| --- | --- |
| `lakehouse.bronze.nyc_taxi_yellow` | Модуль 4 |
| `lakehouse.bronze.taxi_zone_lookup` | Модуль 4 (самостоятельное задание) |
| `lakehouse.silver.nyc_taxi_yellow` | Модуль 5 |
### Часто используемые Spark SQL
```sql
-- Просмотр структуры каталога
SHOW TABLES IN lakehouse.bronze;
-- Метаданные Iceberg
SELECT * FROM <table>.snapshots;
SELECT * FROM <table>.files;
-- Обслуживание (Модуль 8)
CALL lakehouse.system.rewrite_data_files(table => '<namespace>.<table>');
CALL lakehouse.system.expire_snapshots(
table => '<namespace>.<table>',
older_than => TIMESTAMP '<cutoff>',
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>;
```
Для `expire_snapshots` подставь в `<cutoff>` границу хранения истории в часовом поясе Spark-сессии. `retain_last` сохраняет минимум N последних snapshot-ов в цепочке предков текущего состояния, а не ограничивает всю историю числом N. Получение границы времени для учебного примера показано в [Модуле 8, Секции 4](../notebooks/08_maintenance_and_final_lab.ipynb).
### Часто используемые Trino SQL
```sql
SHOW SCHEMAS FROM lakehouse;
SHOW TABLES FROM lakehouse.silver;
DESCRIBE lakehouse.silver.nyc_taxi_yellow;
-- Time travel (синтаксис Trino)
SELECT * FROM <table> FOR VERSION AS OF <snapshot_id>;
```
### Монтирование (хост → контейнер)
| Хост | Контейнер | Режим |
| --- | --- | --- |
| `./notebooks` | `/opt/work` | read-write |
| `./src` | `/opt/src` | read-only |
| `./data` | `/opt/data` | read-only |
## Когда какой документ использовать ## Когда какой документ использовать
- `README.md` — чтобы понять, что это за репозиторий и куда идти дальше. - `README.md` — чтобы понять, что это за репозиторий и куда идти дальше.
- `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1. - `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1.
- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа и smoke-тесты. - `study_workflow.md` — чтобы продолжить занятие, сохранить результат и разобраться с ошибкой.
- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа, шпаргалку и smoke-тесты.
- `course_program.md` — чтобы понять учебную траекторию дальше первого модуля. - `course_program.md` — чтобы понять учебную траекторию дальше первого модуля.
- `glossary.md` — чтобы вернуться к определению термина (storage, catalog, compute, snapshot и др.).
- `mentor_notes.md` — заметки для ведения курса с ментором (опционально).
+160
View File
@@ -0,0 +1,160 @@
# Как заниматься со стендом
Для первой сборки используй [START_HERE](../START_HERE.md), для выбора урока —
[маршрут по модулям](./course_program.md#маршрут-по-модулям). Эта памятка нужна,
когда окружение уже готово.
## Начать следующее занятие
1. Запусти Docker и открой терминал хоста в той же папке репозитория.
2. Выполни:
```bash
docker compose up -d
docker compose ps -a
```
Дождись `healthy` у `ide`, `trino` и `postgres-iceberg`. У двух init-контейнеров
нормальное состояние — `Exited (0)`. Повторять сборку перед каждым занятием
не нужно; она понадобится после изменения образов или зависимостей.
3. Открой [JupyterLab](http://localhost:8888) и нужный ноутбук в списке файлов слева.
При запросе ядра выбери **Python 3 (ipykernel)**.
4. После остановки стенда переменные Python нужно создать заново: выполни
импорты, создание Spark-сессии и подключений в начале текущего ноутбука.
Сохраненный вывод ячеек может быть виден еще до их запуска.
5. Продолжи с места, на котором остановился. Перед повтором ячеек с записью
данных, `INSERT`, `CREATE OR REPLACE`, `DROP` или очисткой истории прочитай
пояснение: повторный запуск может изменить таблицу или создать новый snapshot.
Используй прежнюю папку репозитория: имя Compose-проекта определяет, какие
Docker volumes подключаются. После переименования папки или запуска с другим
`-p` можно получить новый пустой набор данных вместо предыдущего.
Вместе с JupyterLab доступен [VS Code в браузере](./stack_reference.md#vs-code-в-браузере).
Там же описаны выбор ядра и ограничения совместимости. Перед сменой интерфейса
сохрани файл, выполни `spark.stop()` и останови ядро. Файлы общие, переменные
Python — нет; не редактируй один ноутбук в двух интерфейсах одновременно.
## Как проходить ноутбук
Ноутбук состоит из пояснений и кодовых ячеек. Ядро Python выполняет код и хранит
переменные в памяти. Spark-сессия связывает этот Python-процесс с кластером.
- Прочитай пояснение, запусти ячейку через `Shift+Enter`, дождись окончания и
сравни результат с описанием. Следующая ячейка может использовать предыдущие
переменные, поэтому порядок важен.
- В самостоятельном задании заполни `TODO` и проверь результат. **Run → Run All Cells**
запускает код, но не выполняет задание за тебя.
- Checkpoint — вопросы и проверки в конце урока. Покажи результат в своем
стенде и объясни его своими словами перед переходом к следующему модулю.
- Сохраняй изменения через **File → Save Notebook** (`Ctrl+S`; на macOS — `Cmd+S`).
Файл ноутбука вместе с ответами и выводом записывается в `notebooks/` на хосте.
- Перед следующим модулем выполни завершающую ячейку `spark.stop()`, если она
есть; иначе вызови `spark.stop()` после окончания вычислений. Это освобождает
ресурсы Spark для следующего приложения. Закрытие вкладки браузера само по
себе не останавливает вычисления.
Держи одну активную Spark-сессию: если несколько ноутбуков одновременно занимают
все ядра worker-ов, новое задание может ждать ресурсы. Для своих SQL/Python-опытов
можно создать дополнительный ноутбук через **File → New → Notebook**. Он появится
в `notebooks/` на хосте. Примеры `src/` доступны Python по пути `/opt/src` только
для чтения; документацию и конфиги открывай в копии репозитория на хосте.
### Если нужно перезапустить ядро
Сначала сохрани ноутбук. Выбери **Kernel → Restart Kernel…** и подтверди перезапуск.
Затем заново выполни импорты и создание Spark-сессии. Переменные Python исчезнут;
файлы и записанные учебные таблицы сохранятся.
Перезапуск нужен, например, после изменения адреса кластера в
`spark/spark-defaults.conf` или после остановки Spark-сессии, с которой хочешь
продолжить работу. Повторный `getOrCreate()` при живой сессии использует ее;
подробнее это разбирается в Модуле 3.
## Закончить занятие
Сохрани ноутбук, дождись завершения вычислений и запиши для себя номер модуля
и следующую ячейку. Затем в терминале хоста выполни:
```bash
docker compose stop
```
Контейнеры остановятся и освободят занятую ими оперативную память. Файлы
ноутбуков, исходные данные и таблицы останутся. На следующем занятии используй
`docker compose up -d` из начала этой памятки.
## Что и где сохраняется
Docker volume — хранилище вне файловой системы контейнера. В этом стенде два
таких хранилища: `minio-data` с объектами и `postgres-data` с каталогом Iceberg.
Имена volumes в Docker обычно имеют префикс Compose-проекта.
| Что | Где находится | После `stop` или `down` без `-v` |
| --- | --- | --- |
| Код, ответы и сохраненный вывод ноутбуков | `notebooks/` на хосте; `/opt/work` в JupyterLab | Сохраняются |
| Скачанные Parquet и CSV | `data/nyc_taxi/` на хосте; `/opt/data/nyc_taxi` в контейнере | Сохраняются |
| Raw-файлы, данные и metadata-файлы Iceberg | Бакет `lakehouse`, volume `minio-data` | Сохраняются |
| Записи каталога Iceberg | PostgreSQL, volume `postgres-data` | Сохраняются |
| Переменные Python и живая Spark-сессия | Память процессов | Теряются; создай заново |
| Настройки интерфейса JupyterLab | Файловая система контейнера `jupyter` | При `stop` сохраняются; при удалении или пересоздании контейнера могут сброситься |
| Настройки VS Code, доверие рабочей области | Файловая система контейнера `ide` | При `stop` сохраняются; при удалении или пересоздании контейнера могут сброситься |
`docker compose down` удаляет контейнеры и сеть проекта. Именованные volumes
остаются, поэтому после `up -d` таблицы доступны снова. Для обычного окончания
занятия достаточно `stop`. Поведение команд описано также в документации Docker:
[stop](https://docs.docker.com/reference/cli/docker/compose/stop/),
[down](https://docs.docker.com/reference/cli/docker/compose/down/).
### Начать с чистых данных
Этот шаг нужен только для осознанного повторного прохождения с пустым
хранилищем. Он удалит оба volumes: raw-данные в MinIO, таблицы, их историю
и записи каталога PostgreSQL. Сохраненные ноутбуки и скачанные файлы в `data/`
на хосте останутся.
```bash
docker compose down -v
docker compose up -d
```
После сброса в ноутбуках еще может быть старый вывод. Заново создай raw в
Модуле 3, bronze в Модуле 4 и silver в Модуле 5, прежде чем продолжать модули,
которые используют эти данные. Сброс не является способом исправить сеть
или выбор ядра.
## Если что-то не работает
Все `docker compose` команды ниже выполняются в терминале хоста из корня
репозитория. Имя сервиса для логов берется из колонки `SERVICE` в `ps`:
например, `postgres`, хотя контейнер называется `postgres-iceberg`.
| Симптом | Первая проверка | Что сделать дальше |
| --- | --- | --- |
| Docker не отвечает | `docker version`: есть ли раздел Server? | Запусти Docker; при `permission denied` проверь доступ своего пользователя к Docker |
| Образ или зависимость не скачивается | Найди адрес источника и сервис в ошибке | Пройди [диагностику сети](./network_access.md) |
| `port is already allocated` / `address already in use` | Какой порт занят, не запущена ли другая копия стенда? | Освободи нужный порт; [список портов](../START_HERE.md#порты-стенда) |
| Сервис завершился, `unhealthy` или долго `starting` | `docker compose ps -a`, затем `docker compose logs --tail=100 <service>` | Для `postgres-iceberg` используй сервис `postgres`; проверь первую ошибку в логах |
| JupyterLab не открывается | `docker compose ps -a jupyter`, логи `jupyter` | Открой `http://localhost:8888`; для сервера используй [SSH-туннель](./stack_reference.md#jupyterlab) |
| VS Code не открывает ноутбук или сообщает об ошибке webview / service worker | Адрес `http://localhost:8889`, отдельная вкладка | Проверь [ограничения совместимости](./stack_reference.md#vs-code-в-браузере); продолжи в JupyterLab |
| Не выбирается ядро, нет `pyspark` | Выбрано **Python 3 (ipykernel)** в JupyterLab стенда? | Повтори [выбор ядра](../START_HERE.md#как-работать-с-ноутбуками); проверь логи `jupyter` |
| `NameError`, потерялась переменная | Ядро перезапускалось или выбран другой ноутбук? | Выполни импорты и подготовительные ячейки по порядку |
| Spark долго ждет, `Initial job has not accepted any resources` | В Spark UI два worker-а `ALIVE`? Есть другие приложения в `Running Applications`? | Заверши свою предыдущую сессию через `spark.stop()`; проверь логи worker-ов |
| Ядро умерло, контейнер убит, подозрение на память | `docker stats --no-stream`, лимит памяти Docker/WSL2 | Сверь [системные требования](../START_HERE.md#системные-требования), закрой другие вычисления; одного кода 137 недостаточно для вывода об OOM |
| Ноутбук не сохраняется (`Permission denied`) | Это файл из `notebooks/`? Совпадает UID? | Проверь `.env` и [настройку прав перед сборкой](../START_HERE.md#быстрый-запуск); после изменения UID/GID повтори `docker compose build`, затем `docker compose up -d` |
| Нет файлов Taxi или таблицы из прошлого модуля | Файлы в `data/nyc_taxi`, предыдущие модули, прежняя папка проекта | Для raw нужен Модуль 3, для bronze — 4, для silver — 5; после сброса восстанови их по порядку |
### Что передать ментору
Укажи ОС, модуль, ячейку или команду, ожидаемый результат и полный текст ошибки.
Приложи вывод:
```bash
docker compose version
docker compose ps -a
docker compose logs --tail=100 jupyter
```
В последней команде замени `jupyter` на проблемный сервис, если ошибка в другом компоненте.
Для ошибки ноутбука добавь имя выбранного ядра и вывод ячейки. Пароли, токены
и адреса прокси с учетными данными в сообщение не включай.
+35
View File
@@ -0,0 +1,35 @@
# Compose передаёт результат сборки jupyter через additional_contexts.
FROM jupyter-runtime
USER root
ARG CODE_SERVER_VERSION=4.141.0
ARG PYTHON_EXTENSION_VERSION=2026.4.0
ARG JUPYTER_EXTENSION_VERSION=2025.9.1
# Контрольные суммы .deb из выпуска code-server v4.141.0.
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) checksum=f384beb4f3ba207e0bca27d0dbd36dd825d5aa909d78c6d4baa8703eaf6b77ba ;; \
arm64) checksum=0bca33a9b01180ad88f0b8969a8e34ae1c76417df471697e7cd65c20ef5dd60d ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
curl -fL --retry 3 \
"https://github.com/coder/code-server/releases/download/v${CODE_SERVER_VERSION}/code-server_${CODE_SERVER_VERSION}_${arch}.deb" \
-o /tmp/code-server.deb; \
echo "$checksum /tmp/code-server.deb" | sha256sum -c -; \
apt-get update; \
apt-get install -y --no-install-recommends /tmp/code-server.deb; \
rm -rf /tmp/code-server.deb /var/lib/apt/lists/*
USER jovyan
ENV HOME=/home/jovyan
RUN code-server --install-extension "ms-python.python@${PYTHON_EXTENSION_VERSION}" \
&& code-server --install-extension "ms-toolsai.jupyter@${JUPYTER_EXTENSION_VERSION}"
COPY lakehouse.code-workspace /opt/lakehouse.code-workspace
COPY --chown=jovyan settings.json /home/jovyan/.local/share/code-server/User/settings.json
EXPOSE 8889
CMD ["code-server", "--bind-addr", "0.0.0.0:8889", "--auth", "none", \
"--disable-telemetry", "--disable-update-check", \
"/opt/lakehouse.code-workspace"]
+10
View File
@@ -0,0 +1,10 @@
{
"folders": [
{ "name": "Ноутбуки", "path": "/opt/course/notebooks" },
{ "name": "Примеры (только чтение)", "path": "/opt/course/src" },
{ "name": "Документация (только чтение)", "path": "/opt/course/docs" }
],
"settings": {
"python.defaultInterpreterPath": "/usr/bin/python3"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extensions.autoUpdate": false,
"extensions.autoCheckUpdates": false,
"telemetry.telemetryLevel": "off",
"workbench.startupEditor": "none",
"chat.disableAIFeatures": true,
"workbench.secondarySideBar.defaultVisibility": "hidden"
}
+3 -2
View File
@@ -1,5 +1,5 @@
# Берем за основу контейнер, приготовленный для spark в spark/Dockerfile # Берем за основу контейнер, приготовленный для spark в spark/Dockerfile
FROM de-spark-iceberg:3.5.1 FROM spark-runtime
USER root USER root
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
@@ -8,7 +8,8 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN pip3 install --no-cache-dir \ RUN pip3 install --no-cache-dir \
"jupyterlab==4.2.5" \ "jupyterlab==4.2.5" \
"boto3>=1.35,<2" \ "boto3>=1.35,<2" \
"psycopg2-binary>=2.9,<3" "psycopg2-binary>=2.9,<3" \
"trino>=0.328"
# Создаём непривилегированного пользователя # Создаём непривилегированного пользователя
ARG NB_USER=jovyan ARG NB_USER=jovyan
+21 -16
View File
@@ -12,10 +12,10 @@
"На выходе ты должен:\n", "На выходе ты должен:\n",
"\n", "\n",
"- понимать, что стенд уже поднят на хосте;\n", "- понимать, что стенд уже поднят на хосте;\n",
"- подключиться к Spark-кластеру из Jupyter;\n", "- подключиться к Spark-кластеру из ноутбука;\n",
"- выполнить базовый smoke test;\n", "- выполнить базовый smoke test;\n",
"- сопоставить сервисы стенда с их ролями;\n", "- сопоставить сервисы стенда с их ролями;\n",
"- знать первый маршрут диагностики через `docker compose ps`, логи и UI.\n" "- знать первый маршрут диагностики через `docker compose ps -a`, логи и UI.\n"
] ]
}, },
{ {
@@ -25,15 +25,16 @@
"source": [ "source": [
"## Перед стартом\n", "## Перед стартом\n",
"\n", "\n",
"Перед запуском этого ноутбука пройди шаги из `START_HERE.md`.\n", "Если стенд еще не готов, выполни подготовку и запуск по [START_HERE](../START_HERE.md#быстрый-запуск).\n",
"Если возвращаешься после паузы, используй [памятку учащегося](../docs/study_workflow.md).\n",
"\n", "\n",
"Важно: `docker compose build`, `docker compose up -d`, `docker compose ps` и просмотр логов выполняются на хосте, а не внутри Jupyter.\n", "Важно: `docker compose build`, `docker compose up -d`, `docker compose ps -a` и просмотр логов выполняются в терминале хоста; терминал JupyterLab работает внутри контейнера.\n",
"\n", "\n",
"Ожидаемое состояние перед началом:\n", "Ожидаемое состояние перед началом:\n",
"\n", "\n",
"- контейнеры `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter` уже подняты;\n", "- контейнеры `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter`, `ide` уже подняты;\n",
"- открываются `Spark UI`, `Trino UI`, `MinIO Console` и `JupyterLab`;\n", "- открываются `Spark UI`, `Trino UI`, `MinIO Console` и JupyterLab;\n",
"- ты работаешь в Jupyter внутри контейнера `jupyter`.\n" "- в JupyterLab выбрано ядро `Python 3 (ipykernel)` внутри контейнера `jupyter` (Kernel → Change Kernel…).\n"
] ]
}, },
{ {
@@ -49,7 +50,7 @@
"| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg |\n", "| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg |\n",
"| `Spark` | `compute`: выполняет PySpark и Spark SQL |\n", "| `Spark` | `compute`: выполняет PySpark и Spark SQL |\n",
"| `Trino` | `compute`: читает те же таблицы через общий каталог |\n", "| `Trino` | `compute`: читает те же таблицы через общий каталог |\n",
"| `Jupyter` | Точка входа в учебные ноутбуки |\n", "| `JupyterLab` | Точка входа в учебные ноутбуки |\n",
"\n", "\n",
"В этом модуле мы не строим пайплайн и не создаём Iceberg-таблицы. Задача только одна: убедиться, что среда рабочая и понятная.\n" "В этом модуле мы не строим пайплайн и не создаём Iceberg-таблицы. Задача только одна: убедиться, что среда рабочая и понятная.\n"
] ]
@@ -120,7 +121,7 @@
"\n", "\n",
"Если тест проходит, это означает минимум следующее:\n", "Если тест проходит, это означает минимум следующее:\n",
"\n", "\n",
"- Jupyter может создать SparkSession;\n", "- ядро ноутбука может создать SparkSession;\n",
"- Spark подключён к кластерному master;\n", "- Spark подключён к кластерному master;\n",
"- задание действительно выполняется, а не падает на первом действии.\n" "- задание действительно выполняется, а не падает на первом действии.\n"
] ]
@@ -146,10 +147,10 @@
"\n", "\n",
"Сделай руками и проверь себя:\n", "Сделай руками и проверь себя:\n",
"\n", "\n",
"1. Открой `Spark UI`, `Trino UI`, `MinIO Console` и `JupyterLab`.\n", "1. Открой `Spark UI`, `Trino UI`, `MinIO Console` и JupyterLab.\n",
"2. Сопоставь каждый сервис с одной из ролей: `storage`, `catalog`, `compute`, `entrypoint`.\n", "2. Сопоставь каждый сервис с одной из ролей: `storage`, `catalog`, `compute`, `entrypoint`.\n",
"3. Объясни, почему этот smoke test не должен работать в `local[*]` режиме.\n", "3. Объясни, почему этот smoke test не должен работать в `local[*]` режиме.\n",
"4. На хосте выполни `docker compose ps` и посмотри, какие контейнеры должны быть в состоянии `Up`.\n", "4. На хосте выполни `docker compose ps -a` и посмотри, какие контейнеры должны быть в состоянии `Up`.\n",
"5. На хосте выполни `docker compose logs -f trino` или `docker compose logs -f spark-master`, чтобы посмотреть, где начинается первичная диагностика.\n" "5. На хосте выполни `docker compose logs -f trino` или `docker compose logs -f spark-master`, чтобы посмотреть, где начинается первичная диагностика.\n"
] ]
}, },
@@ -166,7 +167,7 @@
" \"PostgreSQL\": \"TODO\",\n", " \"PostgreSQL\": \"TODO\",\n",
" \"Spark\": \"TODO\",\n", " \"Spark\": \"TODO\",\n",
" \"Trino\": \"TODO\",\n", " \"Trino\": \"TODO\",\n",
" \"Jupyter\": \"TODO\",\n", " \"IDE\": \"TODO\",\n",
"}\n", "}\n",
"service_roles\n" "service_roles\n"
] ]
@@ -181,10 +182,14 @@
"К концу модуля ты должен уметь подтвердить:\n", "К концу модуля ты должен уметь подтвердить:\n",
"\n", "\n",
"- стенд поднят и основные контейнеры живы;\n", "- стенд поднят и основные контейнеры живы;\n",
"- `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab` открываются;\n", "- `Spark UI`, `MinIO Console`, `Trino UI` и JupyterLab открываются;\n",
"- smoke test завершился успешно и вернул ожидаемый результат;\n", "- smoke test напечатал `Spark cluster smoke test passed.` и `result_sum=499999500000`;\n",
"- ты понимаешь назначение `MinIO`, `PostgreSQL`, `Spark`, `Trino`, `Jupyter`;\n", "- ты понимаешь назначение `MinIO`, `PostgreSQL`, `Spark`, `Trino` и IDE;\n",
"- ты знаешь, что первый маршрут диагностики начинается с `docker compose ps`, `docker compose logs -f <service>` и проверки UI.\n" "- ты знаешь, что первый маршрут диагностики начинается с `docker compose ps -a`, `docker compose logs -f <service>` и проверки UI.\n",
"\n",
"После последней ячейки `spark.stop()` сохрани ноутбук. Дальше — [Модуль 2](./02_lakehouse_mental_model.ipynb)\n",
"или [завершение занятия](../docs/study_workflow.md#закончить-занятие).\n",
"Все уроки собраны в [маршруте по модулям](../docs/course_program.md#маршрут-по-модулям)."
] ]
}, },
{ {
+5 -5
View File
@@ -25,8 +25,8 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"# Мы не указываем .master() явно, так как он и другие настройки Iceberg\n", "# Адрес Spark-кластера и настройки Iceberg берутся из spark-defaults.conf.\n",
"# подтягиваются из конфигурации spark-defaults.conf при старте сессии.\n", "# Поэтому в ноутбуке достаточно задать имя приложения.\n",
"from pyspark.sql import SparkSession\n", "from pyspark.sql import SparkSession\n",
"\n", "\n",
"spark = SparkSession.builder \\\n", "spark = SparkSession.builder \\\n",
@@ -264,9 +264,9 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"# Удаляем таблицы (это удалит их и из каталога, и из хранилища)\n", "# PURGE удаляет одноразовые таблицы и из каталога, и из хранилища\n",
"spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.demo_table\")\n", "spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.demo_table PURGE\")\n",
"spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.my_first_table\")\n", "spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.my_first_table PURGE\")\n",
"\n", "\n",
"# Удаляем namespace\n", "# Удаляем namespace\n",
"spark.sql(\"DROP NAMESPACE IF EXISTS lakehouse.module_02\")\n", "spark.sql(\"DROP NAMESPACE IF EXISTS lakehouse.module_02\")\n",
+330 -48
View File
@@ -7,15 +7,9 @@
"source": [ "source": [
"# Модуль 3. Raw-данные и первое чтение в Spark\n", "# Модуль 3. Raw-данные и первое чтение в Spark\n",
"\n", "\n",
"В этом модуле мы разбираем полный первый цикл работы с учебным датасетом в Lakehouse:\n", "В прошлом модуле мы проследили, как Spark работает с каталогом и файлами в MinIO. Теперь загрузим настоящие данные: поездки NYC Taxi и справочник зон.\n",
"\n", "\n",
"- проверяем заранее скачанный локальный data bundle;\n", "Сначала подготовим Spark-сессию и проверим подключение. Затем перенесем заранее скачанные файлы в raw-зону MinIO, прочитаем их через Spark и посмотрим на схему, пропуски и аномалии. Эти файлы останутся источником для таблиц, которые мы построим в следующих модулях.\n"
"- загружаем исходные файлы в raw-зону `MinIO`;\n",
"- читаем raw-данные через `Spark` по `s3a://` пути;\n",
"- смотрим схему, типы, базовые метрики и простые аномалии;\n",
"- фиксируем, почему raw-слой нужен как воспроизводимая неизменяемая точка входа.\n",
"\n",
"После этого ноутбука ты должен уметь объяснить, почему raw-данные не надо «чинить на месте» и почему их не стоит скачивать прямо из ноутбука.\n"
] ]
}, },
{ {
@@ -25,20 +19,319 @@
"source": [ "source": [
"## 0. Перед стартом\n", "## 0. Перед стартом\n",
"\n", "\n",
"Перед выполнением ноутбука:\n", "- Подними стенд по `START_HERE.md` и пройди модули 1 и 2.\n",
"- Скачай data bundle в `./data/nyc_taxi` по инструкции из `START_HERE.md`.\n",
"- Открой этот ноутбук в новом ядре, чтобы начать без сессии от предыдущих опытов.\n"
]
},
{
"cell_type": "markdown",
"id": "session-intro",
"metadata": {},
"source": [
"## 1. Как настроен Spark в нашем стенде\n",
"\n", "\n",
"- подними стенд по `START_HERE.md`;\n", "До сих пор мы пользовались готовым объектом `spark`. Разберем, как его создать и куда он отправляет задания: это понадобится при чтении файлов Taxi.\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", "\n",
"В классическом `PostgreSQL / Greenplum` данные часто уже лежат внутри системы. В Lakehouse путь более явный: сначала исходные файлы должны попасть в object storage, и только потом мы начинаем поверх них читать, профилировать и строить таблицы.\n", "`SparkSession` дает доступ к DataFrame и SQL. Его `SparkContext` связывает приложение с вычислительными ресурсами. В обычном ядре нашего ноутбука используется один работающий контекст.\n",
"\n", "\n",
"| | Классический DWH | Lakehouse |\n", "| Участник | Что делает в стенде |\n",
"| --- | --- | --- |\n", "| --- | --- |\n",
"| Где стартует работа | Данные уже внутри СУБД | Исходные файлы сначала попадают в storage |\n", "| Driver | Работает в `jupyter`: выполняет Python-код, строит план и раздает задания |\n",
"| Первая практическая задача | `SELECT` из готовой таблицы | Загрузить raw и прочитать raw |\n", "| Master | Принимает подключение по `spark://spark-master:7077` и выделяет ресурсы worker-ов |\n",
"| Что важно не сломать | Таблицу / схему БД | Неизменяемый raw-слой |\n" "| Executors | Работают на `spark-worker-1` и `spark-worker-2`, читают данные и выполняют задания |\n",
"\n",
"В режиме `local[*]` задания выполняются в потоках на машине driver-а. Нам нужен кластер, поэтому проверим фактический master вместе с результатом вычисления.\n",
"\n",
"Создадим сессию. `.appName(...)` задает имя приложения в Spark UI, `.getOrCreate()` создает сессию или возвращает уже работающую. Адрес кластера пока возьмем из общего файла настроек.\n"
]
},
{
"cell_type": "code",
"id": "session-demo",
"metadata": {},
"source": [
"from pyspark.sql import SparkSession\n",
"\n",
"spark = (\n",
" SparkSession.builder\n",
" .appName(\"module-03-session-demo\")\n",
" .getOrCreate()\n",
")\n",
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
"\n",
"print(\"Spark:\", spark.version)\n",
"print(\"Master:\", spark.sparkContext.master)\n",
"print(\"Приложение:\", spark.sparkContext.appName)\n",
"assert spark.sparkContext.master == \"spark://spark-master:7077\", \"Нужен Spark-кластер стенда\"\n",
"print(\"Сумма 0…9:\", spark.range(10).groupBy().sum(\"id\").first()[0])"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-config-explanation",
"metadata": {},
"source": [
"### Откуда взялось подключение\n",
"\n",
"Master мы не указали в коде: Spark прочитал его из `spark/spark-defaults.conf`. Docker подключает этот файл к Jupyter как `/opt/spark/conf/spark-defaults.conf`. Там же подготовлены каталог Iceberg и доступ к MinIO.\n",
"\n",
"| Настройка в файле | Для чего нужна |\n",
"| --- | --- |\n",
"| `spark.sql.extensions` | Добавляет команды Iceberg к Spark SQL |\n",
"| `spark.sql.catalog.lakehouse` | Подключает Spark-каталог с именем `lakehouse` |\n",
"| `spark.sql.catalog.lakehouse.catalog-impl` и `.uri` | Выбирает JDBC-каталог в PostgreSQL, где зарегистрированы таблицы |\n",
"| `spark.sql.catalog.lakehouse.warehouse` | Задает место для файлов таблиц: `s3a://lakehouse/warehouse` |\n",
"| `spark.hadoop.fs.s3a.endpoint` и `.path.style.access` | Направляют S3A-запросы в MinIO внутри Docker-сети |\n",
"\n",
"При запуске через `spark-submit` настройки можно передать параметрами `--master`, `--name` и `--conf`. В коде для этого есть `.master(...)`, `.appName(...)` и `.config(...)`. Для нового приложения обычный приоритет: код -> параметры запуска -> файл defaults. Параметры запуска driver-а надо задать до его старта; уже работающий контекст новый builder не перестраивает.\n",
"\n",
"В нашем Jupyter дополнительные `--master` и `--conf` при запуске не передаются. Вне стенда потребуются также библиотеки Iceberg, S3A и JDBC: их состав и конфигурация описаны в [справочнике](../docs/stack_reference.md#spark).\n",
"\n",
"Посмотрим, какие значения получила сессия. В файле есть и учетные данные, поэтому выводим только выбранные несекретные настройки. Для SQL используем `spark.conf`; в Hadoop-настройках префикс `spark.hadoop.` уже снят.\n"
]
},
{
"cell_type": "code",
"id": "session-config",
"metadata": {},
"source": [
"for key in (\n",
" \"spark.sql.extensions\",\n",
" \"spark.sql.catalog.lakehouse\",\n",
" \"spark.sql.catalog.lakehouse.catalog-impl\",\n",
" \"spark.sql.catalog.lakehouse.uri\",\n",
" \"spark.sql.catalog.lakehouse.warehouse\",\n",
"):\n",
" print(f\"{key} = {spark.conf.get(key)}\")\n",
"\n",
"hadoop_conf = spark.sparkContext._jsc.hadoopConfiguration()\n",
"for key in (\"fs.s3a.endpoint\", \"fs.s3a.path.style.access\"):\n",
" print(f\"{key} = {hadoop_conf.get(key)}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-live-explanation",
"metadata": {},
"source": [
"### Изменение настройки в работающей сессии\n",
"\n",
"Некоторые настройки можно менять, пока приложение работает. Например, `spark.sql.session.timeZone` определяет часовой пояс SQL-сессии. Посмотрим, как одна и та же секунда Unix-времени отображается в UTC и Москве.\n",
"\n",
"В Spark 3.5.1 эта настройка изменяется через `spark.conf.set(...)`. Сохраним исходное значение и вернем его в `finally`, в том числе при ошибке. Файлы данных этот опыт не меняет.\n"
]
},
{
"cell_type": "code",
"id": "session-live-config",
"metadata": {},
"source": [
"timezone_key = \"spark.sql.session.timeZone\"\n",
"original_timezone = spark.conf.get(timezone_key)\n",
"assert spark.conf.isModifiable(timezone_key)\n",
"try:\n",
" for timezone in (\"UTC\", \"Europe/Moscow\"):\n",
" spark.conf.set(timezone_key, timezone)\n",
" value = spark.sql(\"SELECT from_unixtime(0) AS displayed_time\").first()[0]\n",
" print(timezone, value)\n",
"finally:\n",
" spark.conf.set(timezone_key, original_timezone)\n",
"\n",
"assert spark.conf.get(timezone_key) == original_timezone\n",
"print(\"Исходный часовой пояс восстановлен:\", original_timezone)"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-reuse-explanation",
"metadata": {},
"source": [
"### Повторный вызов builder-а\n",
"\n",
"Теперь попробуем сменить имя приложения на `module-03-raw-ingest-and-first-read`, с которым будем читать Taxi. Проверим имя у работающего контекста: `spark.sparkContext.appName`.\n"
]
},
{
"cell_type": "code",
"id": "session-reuse",
"metadata": {},
"source": [
"demo_context = spark.sparkContext\n",
"demo_name = demo_context.appName\n",
"same_spark = (\n",
" SparkSession.builder\n",
" .appName(\"module-03-raw-ingest-and-first-read\")\n",
" .getOrCreate()\n",
")\n",
"assert same_spark is spark\n",
"assert same_spark.sparkContext is demo_context\n",
"assert same_spark.sparkContext.appName == demo_name\n",
"print(\"То же приложение:\", same_spark.sparkContext.appName)"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-stop-explanation",
"metadata": {},
"source": [
"Имя осталось прежним: `getOrCreate()` вернул ту же сессию с тем же контекстом. Повтор ячейки сохраняет и переменные Python, и работающее приложение.\n",
"\n",
"Чтобы создать приложение с новым именем, вызовем `spark.stop()`. Этот вызов останавливает сессию и ее контекст. Переменные Python останутся, но старые Spark DataFrame использовать уже нельзя. Файлы MinIO и таблицы каталога сохранятся.\n",
"\n",
"Для смены master или настроек в общем файле перезапускай ядро: одной остановки сессии недостаточно для всех параметров запуска. Перезапуск удалит переменные Python, и тогда придется повторить импорты, создание сессии и чтение DataFrame.\n"
]
},
{
"cell_type": "code",
"id": "session-stop",
"metadata": {},
"source": [
"spark.stop()"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-exercise-explanation",
"metadata": {},
"source": [
"## 2. Подключись к Spark самостоятельно\n",
"\n",
"Создай сессию, с которой продолжишь работу с Taxi. Заполни в следующей ячейке адрес кластера, имя `module-03-raw-ingest-and-first-read` и цепочку `.master(...)`, `.appName(...)`, `.getOrCreate()`.\n",
"\n",
"Настройки каталога возьми из общего файла. Адрес кластера в этот раз задай явно через `.master(...)`; остальные ноутбуки берут его из того же файла.\n",
"\n",
"Пока пропуски не заполнены, шаблон останавливается на `assert`. Выполни заполненный шаблон и проверку подключения дважды: имя и `applicationId` должны остаться теми же.\n"
]
},
{
"cell_type": "code",
"id": "session-exercise",
"metadata": {},
"source": [
"from pyspark.sql import SparkSession\n",
"\n",
"CLUSTER_MASTER = ...\n",
"APP_NAME = ...\n",
"assert CLUSTER_MASTER == \"spark://spark-master:7077\", \"Впиши адрес кластера\"\n",
"assert APP_NAME == \"module-03-raw-ingest-and-first-read\", \"Впиши имя приложения\"\n",
"\n",
"# Замени ... цепочкой вызовов SparkSession.builder.\n",
"spark = ...\n",
"assert isinstance(spark, SparkSession), \"Создай SparkSession через builder\"\n",
"spark.sparkContext.setLogLevel(\"ERROR\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-solution",
"metadata": {},
"source": [
"<details>\n",
"<summary>Контрольное решение (после своей попытки)</summary>\n",
"\n",
"```python\n",
"from pyspark.sql import SparkSession\n",
"\n",
"CLUSTER_MASTER = \"spark://spark-master:7077\"\n",
"APP_NAME = \"module-03-raw-ingest-and-first-read\"\n",
"spark = (\n",
" SparkSession.builder\n",
" .master(CLUSTER_MASTER)\n",
" .appName(APP_NAME)\n",
" .getOrCreate()\n",
")\n",
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
"```\n",
"\n",
"</details>"
]
},
{
"cell_type": "code",
"id": "session-connection-check",
"metadata": {},
"source": [
"assert spark.sparkContext.master == CLUSTER_MASTER\n",
"assert spark.sparkContext.appName == APP_NAME\n",
"print(\"Master:\", spark.sparkContext.master)\n",
"print(\"Приложение:\", spark.sparkContext.appName)\n",
"print(\"ID приложения:\", spark.sparkContext.applicationId)"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-table-explanation",
"metadata": {},
"source": [
"### Запись и чтение через каталог\n",
"\n",
"Проверим, что сессия может работать с каталогом и MinIO. Следующая ячейка создаст собственную маленькую Iceberg-таблицу, прочитает из нее числа `0` и `1` и удалит ее в `finally`. Она работает и после удаления таблиц из модуля 2.\n",
"\n",
"У таблицы уникальное имя. `DROP TABLE ... PURGE` удаляет ее запись в каталоге, данные и метаданные; в MinIO могут остаться пустые маркеры `data/` и `metadata/`.\n",
"\n",
"Дождись завершения ячейки перед перезапуском ядра: при принудительной остановке процесса `finally` не выполнится. Если прервал ее, сохрани напечатанное имя, чтобы удалить эту таблицу после нового подключения.\n"
]
},
{
"cell_type": "code",
"id": "session-table-check",
"metadata": {},
"source": [
"from uuid import uuid4\n",
"\n",
"session_table = f\"lakehouse.default.session_practice_{uuid4().hex}\"\n",
"print(\"Временная таблица:\", session_table)\n",
"try:\n",
" spark.range(2).writeTo(session_table).using(\"iceberg\").create()\n",
" session_rows = [row.id for row in spark.table(session_table).orderBy(\"id\").collect()]\n",
" assert session_rows == [0, 1]\n",
" print(\"Прочитано через Iceberg:\", session_rows)\n",
"finally:\n",
" spark.sql(f\"DROP TABLE IF EXISTS {session_table} PURGE\")\n",
"\n",
"assert not spark.catalog.tableExists(session_table)\n",
"print(\"Временная таблица удалена\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "session-checkpoint",
"metadata": {},
"source": [
"### Проверь запуск в новом ядре\n",
"\n",
"В выводе должны быть master `spark://spark-master:7077`, заданное имя приложения, строки `[0, 1]` и сообщение об удалении таблицы.\n",
"\n",
"Перезапусти ядро и повтори заполненный шаблон, проверку подключения и ячейку с таблицей. `applicationId` будет новым, остальные результаты должны совпасть. Импорт `SparkSession` уже есть в шаблоне, поэтому демонстрацию повторять не нужно.\n",
"\n",
"С этим подключением перейдем к файлам Taxi. Оставь сессию работать.\n"
]
},
{
"cell_type": "markdown",
"id": "raw-preparation",
"metadata": {},
"source": [
"## 3. Проверка data bundle\n",
"\n",
"Подготовим MinIO-клиент для загрузки файлов и пути к данным. Docker подключает каталог `data/` с хоста к Jupyter как `/opt/data/`.\n"
] ]
}, },
{ {
@@ -63,10 +356,6 @@
"RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n", "RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n",
"ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n", "ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n",
"\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 = boto3.client(\n",
" \"s3\",\n", " \"s3\",\n",
" endpoint_url=\"http://minio:9000\",\n", " endpoint_url=\"http://minio:9000\",\n",
@@ -127,7 +416,7 @@
"\n", "\n",
"\n", "\n",
"print(\"Spark и MinIO-клиент готовы\")\n", "print(\"Spark и MinIO-клиент готовы\")\n",
"print(f\"RAW URI: {RAW_URI}\")\n" "print(f\"RAW URI: {RAW_URI}\")"
] ]
}, },
{ {
@@ -135,15 +424,7 @@
"id": "d4d8f302", "id": "d4d8f302",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 1. Проверка data bundle\n", "Файлы должны быть скачаны заранее по `START_HERE.md`. Здесь проверим состав набора, прежде чем отправлять его в MinIO. Так во время урока нам не понадобится внешняя сеть, а при повторном прохождении мы будем читать те же исходные файлы.\n"
"\n",
"Сначала убеждаемся, что нужные файлы уже лежат внутри Jupyter-контейнера по пути `/opt/data/nyc_taxi`.\n",
"\n",
"Почему не скачиваем данные прямо здесь:\n",
"\n",
"- так модуль остаётся воспроизводимым и не зависит от сетевого доступа внутри ноутбука;\n",
"- onboarding и подготовка окружения остаются в `START_HERE.md`, а практика в ноутбуке занимается только Lakehouse-операциями;\n",
"- студент видит границу ответственности: сначала подготовить bundle, потом работать с raw-слоем.\n"
] ]
}, },
{ {
@@ -198,7 +479,7 @@
"id": "dabd90f2", "id": "dabd90f2",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 2. Загрузка в raw-зону MinIO\n", "## 4. Загрузка в raw-зону MinIO\n",
"\n", "\n",
"Raw-слой в Lakehouse похож на воспроизводимый входной `stg`-контур: мы сохраняем исходные файлы как есть, под оригинальными именами, не переписывая их содержимое.\n", "Raw-слой в Lakehouse похож на воспроизводимый входной `stg`-контур: мы сохраняем исходные файлы как есть, под оригинальными именами, не переписывая их содержимое.\n",
"\n", "\n",
@@ -296,7 +577,7 @@
"id": "696d6adc", "id": "696d6adc",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 3. Первое чтение raw PARQUET через Spark\n", "## 5. Первое чтение raw PARQUET через Spark\n",
"\n", "\n",
"Теперь читаем raw-файлы напрямую из `MinIO` по `s3a://` пути. Это и есть первая важная практика Lakehouse: вычислитель (`Spark`) работает поверх файлов в object storage, а не только поверх локальной файловой системы или таблиц внутри одной СУБД.\n", "Теперь читаем raw-файлы напрямую из `MinIO` по `s3a://` пути. Это и есть первая важная практика Lakehouse: вычислитель (`Spark`) работает поверх файлов в object storage, а не только поверх локальной файловой системы или таблиц внутри одной СУБД.\n",
"\n", "\n",
@@ -305,7 +586,7 @@
"- `spark.read.parquet(\"s3a://...\")` читает raw-файлы напрямую по физическому пути;\n", "- `spark.read.parquet(\"s3a://...\")` читает raw-файлы напрямую по физическому пути;\n",
"- `spark.table(\"lakehouse....\")` читает уже зарегистрированную управляемую таблицу через каталог.\n", "- `spark.table(\"lakehouse....\")` читает уже зарегистрированную управляемую таблицу через каталог.\n",
"\n", "\n",
"Сейчас мы ещё не создавали `Iceberg`-таблицу, поэтому работаем именно через `spark.read.parquet(...)`. К `spark.table(...)` вернёмся в Модуле 4.\n" "Для проверки подключения мы читали маленькую таблицу по имени через `spark.table(...)`. Файлы Taxi пока не зарегистрированы в каталоге: прочитаем их по пути через `spark.read.parquet(...)`. Таблицу поездок создадим в модуле 4."
] ]
}, },
{ {
@@ -372,7 +653,7 @@
"id": "9701728a", "id": "9701728a",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 4. Схема, типы и null-значения\n", "## 6. Схема, типы и null-значения\n",
"\n", "\n",
"Следующий шаг после первого чтения: понять, какие колонки пришли, какие у них типы и где уже на raw-слое встречаются `null`.\n", "Следующий шаг после первого чтения: понять, какие колонки пришли, какие у них типы и где уже на raw-слое встречаются `null`.\n",
"\n", "\n",
@@ -435,7 +716,7 @@
"id": "69e6b77e", "id": "69e6b77e",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 5. Базовые метрики и аномалии\n", "## 7. Базовые метрики и аномалии\n",
"\n", "\n",
"Теперь быстро профилируем данные и ищем несколько очевидных проблемных паттернов:\n", "Теперь быстро профилируем данные и ищем несколько очевидных проблемных паттернов:\n",
"\n", "\n",
@@ -554,7 +835,7 @@
"id": "095c9ac6", "id": "095c9ac6",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 6. Самостоятельное задание\n", "## 8. Самостоятельное задание\n",
"\n", "\n",
"Используй raw-данные, которые уже лежат в `MinIO`:\n", "Используй raw-данные, которые уже лежат в `MinIO`:\n",
"\n", "\n",
@@ -612,7 +893,7 @@
"id": "e23dfe47", "id": "e23dfe47",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 7. Почему raw-слой важен\n", "## 9. Почему raw-слой важен\n",
"\n", "\n",
"Raw-слой полезен не потому, что в нём удобно жить постоянно, а потому что он даёт устойчивую исходную точку. Если завтра поменяются правила очистки, дедупликации или нормализации, мы не обязаны заново скачивать источник и гадать, что именно уже успели «подправить». Мы просто перечитываем raw и перестраиваем downstream-слои.\n", "Raw-слой полезен не потому, что в нём удобно жить постоянно, а потому что он даёт устойчивую исходную точку. Если завтра поменяются правила очистки, дедупликации или нормализации, мы не обязаны заново скачивать источник и гадать, что именно уже успели «подправить». Мы просто перечитываем raw и перестраиваем downstream-слои.\n",
"\n", "\n",
@@ -628,16 +909,17 @@
"id": "8849c73f", "id": "8849c73f",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 8. Checkpoint\n", "## 10. Checkpoint\n",
"\n", "\n",
"Проверь себя:\n", "Проверь себя:\n",
"\n", "\n",
"1. Можешь ли ты показать файлы raw-зоны в `MinIO` через код или `MinIO Console`?\n", "1. К какому master подключена сессия? Какие настройки ты задал в коде, а какие она получила из общего файла?\n",
"2. Сколько колонок в схеме `Yellow Taxi` и какие типы там встречаются?\n", "2. Чем SparkSession отличается от SparkContext? Где работают driver и executors?\n",
"3. Какие колонки содержат `null` и почему это не обязательно баг загрузки?\n", "3. Почему часовой пояс изменился сразу, а для нового имени приложения понадобилось остановить сессию? Что придется повторить после перезапуска ядра?\n",
"4. Зачем нужен raw-слой, если дальше всё равно будут трансформации?\n", "4. Можешь ли ты показать загруженные raw-файлы в MinIO и прочитать их через Spark?\n",
"5. Почему мы не скачиваем датасет прямо из ноутбука?\n", "5. Сколько колонок в Yellow Taxi, какие у них типы и где встречаются `null`? Почему пропуски не обязательно означают ошибку загрузки?\n",
"6. Что произойдёт, если источник завтра поменяет формат файлов?\n" "6. Зачем сохранять raw-слой без изменений, если дальше будут трансформации?\n",
"7. Почему мы скачали датасет до запуска ноутбука? Что потребуется проверить, если источник изменит формат файлов?\n"
] ]
}, },
{ {
@@ -645,7 +927,7 @@
"id": "371c4c9d", "id": "371c4c9d",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## 9. Завершение\n", "## 11. Завершение\n",
"\n", "\n",
"Мы намеренно не удаляем raw-данные из `MinIO`. В Модуле 4 они понадобятся для построения первого управляемого `bronze`-слоя на `Iceberg`.\n" "Мы намеренно не удаляем raw-данные из `MinIO`. В Модуле 4 они понадобятся для построения первого управляемого `bronze`-слоя на `Iceberg`.\n"
] ]
+10 -49
View File
@@ -216,7 +216,7 @@
"cell_type": "markdown", "cell_type": "markdown",
"id": "350d2087", "id": "350d2087",
"metadata": {}, "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 мы отдельно разберём, почему в рабочих сценариях нужно аккуратнее относиться к истории таблицы." "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, а старые snapshot-ы сохраняются для time travel.\n\nВажно: замена может изменить текущие данные, схему и партиционирование таблицы. Новый snapshot не продолжает прежнюю цепочку: у него нет `parent_id`, а старые snapshot-ы больше не являются предками текущего состояния (`is_current_ancestor = false`). Это не отдельная именованная ветка — текущей по-прежнему остаётся `main`. Подробнее эту разницу разберём в Модуле 7."
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -564,21 +564,7 @@
"cell_type": "markdown", "cell_type": "markdown",
"id": "320c0199", "id": "320c0199",
"metadata": {}, "metadata": {},
"source": [ "source": "## 8. Вторая bronze-таблица: taxi_zone_lookup\n\nВ raw-зоне кроме Yellow Taxi лежит ещё один файл — `taxi_zone_lookup.csv`. Это справочник зон: каждому `LocationID` соответствует название района (`Borough`) и зоны (`Zone`).\n\nВ Модуле 5 мы будем обогащать silver-таблицу через `LEFT JOIN` с этим справочником. Сейчас загрузим его в bronze по тому же паттерну `CTAS`, но из CSV вместо Parquet."
"## 8. Самостоятельное задание\n",
"\n",
"Используй raw-файл `taxi_zone_lookup.csv`, который уже лежит в `MinIO`.\n",
"\n",
"Что нужно сделать:\n",
"\n",
"1. Прочитать `taxi_zone_lookup.csv` из raw-зоны через `Spark`.\n",
"2. Создать таблицу `lakehouse.bronze.taxi_zone_lookup` с колонками `LocationID INT`, `Borough STRING`, `Zone STRING`, `service_zone STRING`.\n",
"3. Загрузить туда данные.\n",
"4. Проверить результат через `spark.table(...)`.\n",
"5. Посмотреть `snapshots` для этой таблицы.\n",
"\n",
"Подсказка: файл лежит по пути `s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv`.\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -586,9 +572,7 @@
"id": "e94508bf", "id": "e94508bf",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "raw_zone_df = (\n spark.read\n .option(\"header\", \"true\")\n .option(\"inferSchema\", \"true\")\n .csv(ZONE_LOOKUP_RAW_URI)\n)\n\nprint(f\"Строк в raw lookup: {raw_zone_df.count()}\")\nraw_zone_df.printSchema()\nraw_zone_df.show(5, truncate=False)"
"# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -596,19 +580,15 @@
"id": "8e058aeb", "id": "8e058aeb",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "raw_zone_df.createOrReplaceTempView(\"raw_zone_lookup\")\n\nctas_lookup_sql = f\"\"\"\nCREATE OR REPLACE TABLE {BRONZE_LOOKUP_TABLE}\nUSING iceberg\nAS\nSELECT\n CAST(LocationID AS INT) AS LocationID,\n Borough,\n Zone,\n service_zone\nFROM raw_zone_lookup\n\"\"\"\n\nspark.sql(ctas_lookup_sql)\n\nlookup_df = spark.table(BRONZE_LOOKUP_TABLE)\nlookup_count = lookup_df.count()\n\nprint(f\"Строк в bronze lookup: {lookup_count}\")\nlookup_df.show(5, truncate=False)"
"# Ваш код: создай таблицу lakehouse.bronze.taxi_zone_lookup\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "markdown",
"execution_count": null, "execution_count": null,
"id": "41c2c79a", "id": "41c2c79a",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "## 9. Самостоятельное задание\n\nПосмотри на внутреннюю структуру только что созданной `taxi_zone_lookup` — используй те же инструменты, что в Секциях 5 и 6.\n\n1. Выведи `snapshots` и `files` для `lakehouse.bronze.taxi_zone_lookup`.\n2. Сравни: сколько data files у lookup-таблицы и сколько у `nyc_taxi_yellow`? Почему такая разница?"
"# Ваш код: загрузи данные в bronze lookup-таблицу\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -616,9 +596,7 @@
"id": "80b392e1", "id": "80b392e1",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "# Ваш код: snapshots и files для taxi_zone_lookup\n"
"# Ваш код: проверь результат через spark.table(...)\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -626,36 +604,19 @@
"id": "61e90959", "id": "61e90959",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "# Ваш код: сравни количество data files в lookup и yellow taxi\n"
"# Ваш код: посмотри snapshots для lakehouse.bronze.taxi_zone_lookup\n"
]
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
"id": "24c04d60", "id": "24c04d60",
"metadata": {}, "metadata": {},
"source": [ "source": "## 10. Checkpoint\n\nПроверь себя:\n\n1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n2. Что хранит snapshot и зачем он нужен?\n3. Где физически лежат данные таблицы и где лежат её метаданные?\n4. Почему `bronze` это не то же самое, что `raw`?\n5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?"
"## 9. Checkpoint\n",
"\n",
"Проверь себя:\n",
"\n",
"1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n",
"2. Что хранит snapshot и зачем он нужен?\n",
"3. Где физически лежат данные таблицы и где лежат её метаданные?\n",
"4. Почему `bronze` это не то же самое, что `raw`?\n",
"5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n",
"6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?\n"
]
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
"id": "76d9061f", "id": "76d9061f",
"metadata": {}, "metadata": {},
"source": [ "source": "## 11. Завершение\n\nМы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой."
"## 10. Завершение\n",
"\n",
"Мы намеренно не удаляем `lakehouse.bronze.nyc_taxi_yellow`. Эта таблица понадобится в Модуле 5, где мы будем строить `silver`-слой.\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
+613
View File
@@ -0,0 +1,613 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "section_0",
"metadata": {},
"source": [
"# Модуль 5. Слой silver и воспроизводимые трансформации\n",
"\n",
"В этом модуле мы переходим от bronze-таблицы «как есть» к осознанно очищенной и обогащённой silver-таблице.\n",
"\n",
"**Цели модуля:**\n",
"- понять, зачем нужен silver-слой и чем он отличается от bronze;\n",
"- научиться формулировать правила трансформации явно перед написанием кода;\n",
"- отфильтровать некачественные записи, нормализовать типы и обработать NULL;\n",
"- обогатить данные через JOIN с lookup-таблицей;\n",
"- выполнить проверки качества результата на трёх уровнях: строки, схема, агрегаты.\n",
"\n",
"**Prerequisite:**\n",
"- пройден Модуль 4: таблицы `lakehouse.bronze.nyc_taxi_yellow` и `lakehouse.bronze.taxi_zone_lookup` (самостоятельное задание) существуют.\n",
"\n",
"### Связка с привычным DWH (PostgreSQL / Greenplum)\n",
"\n",
"| | Классический DWH (Greenplum) | Lakehouse |\n",
"|---|---|---|\n",
"| Источник | ods/stg-таблица | Bronze Iceberg-таблица |\n",
"| Результат | dds/fact-таблица с очищенными данными | Silver Iceberg-таблица |\n",
"| Правила трансформаций | Mapping specification / ETL-документация | Явный реестр правил в ноутбуке перед кодом |\n",
"| Проверки качества | dq-check скрипты, data quality framework | Asserts на трёх уровнях |\n",
"| Воспроизводимость | Повторный запуск ETL | `CREATE OR REPLACE TABLE ... AS SELECT` |\n",
"\n",
"Ключевая идея: **silver = bronze + явные правила трансформации + проверки качества**."
]
},
{
"cell_type": "markdown",
"id": "section_1_title",
"metadata": {},
"source": [
"## 1. Spark-сессия и проверка bronze\n",
"\n",
"Инициализируем Spark и убеждаемся, что обе bronze-таблицы на месте. Теперь мы работаем только с ними и не обращаемся к raw-файлам напрямую."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "section_1_code",
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"from pyspark.sql import SparkSession, functions as F\n",
"\n",
"CATALOG_NAME = \"lakehouse\"\n",
"BRONZE_NAMESPACE = f\"{CATALOG_NAME}.bronze\"\n",
"BRONZE_TABLE = f\"{BRONZE_NAMESPACE}.nyc_taxi_yellow\"\n",
"BRONZE_LOOKUP_TABLE = f\"{BRONZE_NAMESPACE}.taxi_zone_lookup\"\n",
"\n",
"SILVER_NAMESPACE = f\"{CATALOG_NAME}.silver\"\n",
"SILVER_TABLE = f\"{SILVER_NAMESPACE}.nyc_taxi_yellow\"\n",
"\n",
"spark = SparkSession.builder \\\n",
" .appName(\"module-05-silver-layer\") \\\n",
" .getOrCreate()\n",
"\n",
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
"\n",
"print(\"Spark готов\")\n",
"print(f\"Bronze table: {BRONZE_TABLE}\")\n",
"print(f\"Silver table: {SILVER_TABLE}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "section_1_asserts",
"metadata": {},
"outputs": [],
"source": "assert spark.catalog.tableExists(BRONZE_TABLE), (\n f\"Таблица {BRONZE_TABLE} не найдена. Вернись в Модуль 4 и выполни загрузку в bronze.\"\n)\n\nassert spark.catalog.tableExists(BRONZE_LOOKUP_TABLE), (\n f\"Таблица {BRONZE_LOOKUP_TABLE} не найдена. \"\n \"Вернись в Модуль 4 и выполни Секцию 8.\"\n)\n\nbronze_count = spark.table(BRONZE_TABLE).count()\nassert bronze_count > 0, f\"Таблица {BRONZE_TABLE} пуста.\"\n\nprint(f\"Обе bronze-таблицы на месте. В основной таблице {bronze_count:,} строк. Теперь строим silver.\")"
},
{
"cell_type": "markdown",
"id": "section_2_title",
"metadata": {},
"source": [
"## 2. Профиль bronze — что нужно трансформировать\n",
"\n",
"Прежде чем писать трансформации, понимаем текущее состояние данных. В Модуле 3 мы уже нашли аномалии. Теперь смотрим через bronze."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "section_2_code",
"metadata": {},
"outputs": [],
"source": [
"bronze_df = spark.table(BRONZE_TABLE)\n",
"\n",
"profile_df = bronze_df.select(\n",
" F.count(\"*\").alias(\"total_rows\"),\n",
" (F.sum(F.when(F.col(\"fare_amount\") < 0, 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"negative_fare_pct\"),\n",
" (F.sum(F.when(F.col(\"total_amount\") > 1000, 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"extreme_total_pct\"),\n",
" (F.sum(F.when((F.col(\"tpep_pickup_datetime\") < \"2024-01-01\") | (F.col(\"tpep_pickup_datetime\") >= \"2025-01-01\"), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"out_of_range_date_pct\"),\n",
" (F.sum(F.when(F.col(\"passenger_count\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_passengers_pct\"),\n",
" (F.sum(F.when(F.col(\"RatecodeID\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_ratecode_pct\"),\n",
" (F.sum(F.when(F.col(\"congestion_surcharge\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_congestion_pct\"),\n",
" (F.sum(F.when(F.col(\"Airport_fee\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_airport_fee_pct\")\n",
")\n",
"\n",
"profile_pd = profile_df.toPandas().T\n",
"profile_pd.columns = [\"value\"]\n",
"profile_pd"
]
},
{
"cell_type": "markdown",
"id": "section_2_summary",
"metadata": {},
"source": [
"**Итоги профилирования:**\n",
"\n",
"| Проблема | Описание | Что будем делать |\n",
"|---|---|---|\n",
"| Отрицательные тарифы | Ошибки в данных (fare_amount < 0) | Фильтровать (F1) |\n",
"| Экстремальные суммы | Аномалии (total_amount > 1000) | Фильтровать (F2) |\n",
"| Даты вне диапазона | Записи за пределами 2024 года | Фильтровать (F3) |\n",
"| NULL в passenger_count | Не заполненные данные | Заполнять 0 и приводить к INT (N1) |\n",
"| NULL в RatecodeID | Не заполненные данные | Заполнять 99 (unknown) и приводить к INT (N2) |\n",
"| NULL в сборах | Не заполненные надбавки (surcharges/fees) | Заполнять 0.0 (N3, N4) |"
]
},
{
"cell_type": "markdown",
"id": "section_3_title",
"metadata": {},
"source": [
"## 3. Реестр правил трансформации\n",
"\n",
"Центральная идея модуля: фиксируем все правила явно перед кодом. Это аналог mapping specification в DWH. Если через месяц спросят «почему в silver нет строк с отрицательным fare?», ответ будет в реестре, а не зарыт в коде.\n",
"\n",
"| # | Колонка/область | Правило | Тип | Обоснование |\n",
"|---|---|---|---|---|\n",
"| **F1** | `fare_amount` | `fare_amount >= 0` | Фильтр | Отрицательные тарифы — явная аномалия |\n",
"| **F2** | `total_amount` | `total_amount <= 1000` | Фильтр | Экстремальные суммы — выбросы, искажающие среднее |\n",
"| **F3** | `tpep_pickup_datetime` | `[2024-01-01, 2025-01-01)` | Фильтр | Учебный диапазон данных — 2024 год |\n",
"| **N1** | `passenger_count` | `coalesce(cast(..., int), 0)` | Норм. | Приводим к целому, заменяем NULL на 0 |\n",
"| **N2** | `RatecodeID` | `coalesce(cast(..., int), 99)` | Норм. | Приводим к целому, заменяем NULL на 99 (Unknown) |\n",
"| **N3** | `congestion_surcharge` | `coalesce(..., 0.0)` | Норм. | Убираем NULL для корректности агрегатов |\n",
"| **N4** | `Airport_fee` | `coalesce(..., 0.0)` | Норм. | Убираем NULL для корректности агрегатов |\n",
"| **E1** | `trip_duration_minutes` | `(dropoff - pickup) / 60` | Обог. | Полезная вычисляемая метрика для анализа |\n",
"| **E2** | `pickup_zone/borough` | `LEFT JOIN zone_lookup` | Обог. | Понимаем, откуда поехала машина не по ID, а по имени |\n",
"| **E3** | `dropoff_zone/borough` | `LEFT JOIN zone_lookup` | Обог. | Понимаем, куда поехала машина не по ID, а по имени |"
]
},
{
"cell_type": "markdown",
"id": "section_4_title",
"metadata": {},
"source": [
"## 4. Применение правил — фильтрация\n",
"\n",
"Начинаем с фильтрации. Порядок осознанный: сначала фильтруем, потом нормализуем и обогащаем. Мы не тратим ресурсы на трансформацию строк, которые всё равно будут удалены."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "section_4_code",
"metadata": {},
"outputs": [],
"source": [
"bronze_df = spark.table(BRONZE_TABLE)\n",
"\n",
"filtered_df = (\n",
" bronze_df\n",
" .filter(F.col(\"fare_amount\") >= 0) # F1 \n",
" .filter(F.col(\"total_amount\") <= 1000) # F2 \n",
" .filter((F.col(\"tpep_pickup_datetime\") >= \"2024-01-01\") & (F.col(\"tpep_pickup_datetime\") < \"2025-01-01\")) # F3\n",
")\n",
"\n",
"filtered_count = filtered_df.count()\n",
"removed_count = bronze_count - filtered_count\n",
"removed_pct = (removed_count / bronze_count) * 100\n",
"\n",
"print(f\"Строк до фильтрации: {bronze_count:,}\")\n",
"print(f\"Строк после фильтрации: {filtered_count:,}\")\n",
"print(f\"Удалено аномалий: {removed_count:,} ({removed_pct:.2f}%)\")"
]
},
{
"cell_type": "markdown",
"id": "section_4_note",
"metadata": {},
"source": [
"Обычно фильтры качества удаляют небольшую долю строк. Если фильтр убирает заметную часть данных — это сигнал перепроверить правило или источник."
]
},
{
"cell_type": "markdown",
"id": "section_5_title",
"metadata": {},
"source": [
"## 5. Применение правил — нормализация\n",
"\n",
"Приводим типы и обрабатываем NULL. В PostgreSQL мы бы использовали `ALTER COLUMN TYPE integer`, но в Lakehouse мы создаём новую версию данных между слоями."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "section_5_code",
"metadata": {},
"outputs": [],
"source": [
"normalized_df = (\n",
" filtered_df\n",
" .withColumn(\"passenger_count\", F.coalesce(F.col(\"passenger_count\").cast(\"int\"), F.lit(0))) # N1 \n",
" .withColumn(\"RatecodeID\", F.coalesce(F.col(\"RatecodeID\").cast(\"int\"), F.lit(99))) # N2 \n",
" .withColumn(\"congestion_surcharge\", F.coalesce(F.col(\"congestion_surcharge\"), F.lit(0.0))) # N3 \n",
" .withColumn(\"Airport_fee\", F.coalesce(F.col(\"Airport_fee\"), F.lit(0.0))) # N4\n",
")\n",
"\n",
"normalized_df.select(\"passenger_count\", \"RatecodeID\", \"congestion_surcharge\", \"Airport_fee\").show(5)"
]
},
{
"cell_type": "markdown",
"id": "section_6_title",
"metadata": {},
"source": [
"## 6. Применение правил — обогащение\n",
"\n",
"Добавляем вычисляемые колонки и данные из lookup. Используем `LEFT JOIN`, чтобы не терять строки, если для какого-то ID не нашлось имени зоны. Это аналог денормализации в классическом DWH."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "section_6_code",
"metadata": {},
"outputs": [],
"source": [
"zone_lookup_df = spark.table(BRONZE_LOOKUP_TABLE)\n",
"\n",
"enriched_df = (\n",
" normalized_df\n",
" .withColumn(\"trip_duration_minutes\", (F.unix_timestamp(\"tpep_dropoff_datetime\") - F.unix_timestamp(\"tpep_pickup_datetime\")) / 60) # E1 \n",
" .join(zone_lookup_df.alias(\"pu_zone\"), F.col(\"PULocationID\") == F.col(\"pu_zone.LocationID\"), \"left\") # E2 \n",
" .withColumnRenamed(\"Borough\", \"pickup_borough\") \n",
" .withColumnRenamed(\"Zone\", \"pickup_zone\") \n",
" .drop(\"LocationID\", \"service_zone\") \n",
" .join(zone_lookup_df.alias(\"do_zone\"), F.col(\"DOLocationID\") == F.col(\"do_zone.LocationID\"), \"left\") # E3 \n",
" .withColumnRenamed(\"Borough\", \"dropoff_borough\") \n",
" .withColumnRenamed(\"Zone\", \"dropoff_zone\") \n",
" .drop(\"LocationID\", \"service_zone\")\n",
")\n",
"\n",
"print(f\"Количество колонок после обогащения: {len(enriched_df.columns)}\")\n",
"enriched_df.select(\"tpep_pickup_datetime\", \"trip_duration_minutes\", \"pickup_zone\", \"dropoff_zone\").show(5, truncate=False)"
]
},
{
"cell_type": "markdown",
"id": "section_6_note",
"metadata": {},
"source": [
"**Обрати внимание:**\n",
"- **Отрицательная длительность:** если `tpep_dropoff_datetime` раньше `tpep_pickup_datetime`, колонка `trip_duration_minutes` будет отрицательной. Мы оставим это как наблюдение — это еще одна аномалия данных.\n",
"- **Смешанный регистр колонок:** оригинальные колонки (`VendorID`, `PULocationID`) сохраняют mixed case, тогда как наши новые колонки (`pickup_zone`, `trip_duration_minutes`) написаны в lowercase. `Iceberg` корректно обрабатывает такой набор колонок, но в реальных проектах лучше придерживаться единого стандарта."
]
},
{
"cell_type": "markdown",
"id": "section_7_title",
"metadata": {},
"source": [
"## 7. Создание silver-таблицы\n",
"\n",
"Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) в namespace `lakehouse.silver`.\n",
"\n",
"**Важное предупреждение:** `CREATE OR REPLACE` атомарно заменяет текущее содержимое таблицы и может заменить её схему и партиционирование. При этом Iceberg сохраняет предыдущие snapshot-ы для time travel, но новый 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
}
+926
View File
@@ -0,0 +1,926 @@
{
"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 * FROM <table> TIMESTAMP AS OF '<timestamp>'` |\n",
"| Посмотреть историю изменений таблицы | Нет встроенного механизма | `SELECT * FROM table.snapshots` |\n",
"| Восстановить таблицу после ошибочной записи | Нужен бэкап или ручной откат | `CALL lakehouse.system.rollback_to_snapshot(table => '<namespace>.<table>', snapshot_id => <snapshot_id>);` |\n",
"\n",
"Time travel читает прошлое состояние, не меняя текущую таблицу. Rollback переключает текущее состояние таблицы на выбранный snapshot-предок.\n",
"\n",
"**Ключевая идея:** Iceberg хранит историю изменений данных, пока snapshot-ы не удалены при обслуживании. 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 у silver будет 1 snapshot от CTAS. Каждый повторный `CREATE OR REPLACE` добавит ещё один snapshot: прежние состояния сохранятся, хотя перестанут быть предками текущего.\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` **не создаёт новый snapshot данных**: data files не перезаписываются, при чтении существующих строк новая колонка возвращает NULL.\n",
"\n",
"Изменение схемы записывается в новый файл `*.metadata.json` в MinIO. В списке `schemas` появляется новая версия схемы со своим `schema-id`, а `current-schema-id` указывает на неё как на текущую. JDBC-каталог в PostgreSQL переключает `metadata_location` на новый файл. Так схема получает новую версию, хотя текущий snapshot данных остаётся прежним.\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` исчезнет из текущего состояния. Предыдущие snapshot-ы при этом сохранятся для time travel, но перестанут быть предками текущего snapshot-а.\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 записал новый metadata-файл в MinIO и обновил указатель на него в 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",
"# чтобы при повторном запуске снова получить ровно один snapshot.\n",
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"\n",
"# Создание демо-таблицы\n",
"spark.sql(f\"\"\"\n",
" CREATE 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` читает snapshot, который был текущим в указанный момент времени. Чтобы прочитать конкретный snapshot по его ID, используй `VERSION AS OF`. Оба запроса оставляют текущее состояние таблицы без изменений.\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, parent_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": [
"print(\"Сохранённые snapshot-ы:\")\n",
"spark.sql(f\"SELECT snapshot_id, parent_id, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n",
"\n",
"print(\"История переключений текущего состояния:\")\n",
"spark.sql(\n",
" f\"SELECT made_current_at, snapshot_id, parent_id, is_current_ancestor \"\n",
" f\"FROM {DEMO_TABLE}.history ORDER BY made_current_at\"\n",
").show(truncate=False)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"В `.snapshots` по-прежнему три записи: rollback **не создал четвёртый snapshot**. Он переключил текущее состояние таблицы на уже существующий `snapshot_2`.\n",
"\n",
"В `.history` появилась ещё одна запись о переключении текущего состояния. Она снова указывает на `snapshot_2`; snapshot с «плохими» строками сохранился, но больше не является предком текущего состояния (`is_current_ancestor = false`).\n",
"\n",
"| Свойство | `rollback_to_snapshot` | `CREATE OR REPLACE TABLE ... AS SELECT` |\n",
"|---|---|---|\n",
"| Создаёт новый snapshot | Нет | Да |\n",
"| Что становится текущим | Существующий snapshot-предок | Новый snapshot с результатом `SELECT` |\n",
"| Что происходит со старыми snapshot-ами | Сохраняются | Сохраняются для time travel |\n",
"| Цепочка предков | Возвращаемся к предку | Новый snapshot не имеет `parent_id`; прежние snapshot-ы перестают быть предками текущего |\n",
"\n",
"При `CREATE OR REPLACE` отдельная именованная ветка автоматически не создаётся: текущей остаётся ветка `main`. Старые data files сохраняются до `expire_snapshots` — эту процедуру разберём в Модуле 8.\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` для воспроизводимой полной загрузки bronze и silver. В `SparkCatalog` эта операция атомарно создаёт новый snapshot и сохраняет прежние snapshot-ы для time travel. Но она заменяет текущее содержимое, схему и партиционирование таблицы. Новый snapshot не имеет `parent_id`, поэтому прежние snapshot-ы перестают быть предками текущего состояния. Слепая замена может неожиданно убрать колонку, изменить раскладку данных или усложнить обычный rollback к прежней цепочке. Если нужно перезаписать данные, сохранив текущую схему и партиционирование, используй `INSERT OVERWRITE`.\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",
"Подготовим демо-таблицу с чистого листа. После демонстраций выше её схема изменена, а история содержит учебные эксперименты. Поскольку `CREATE OR REPLACE` сохраняет старые snapshot-ы, для одноразовой учебной таблицы явно используем `DROP TABLE ... PURGE`, а затем новый `CREATE TABLE`. Так самостоятельная работа снова начнётся ровно с одного snapshot-а.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Эта таблица одноразовая: PURGE удаляет её данные и snapshot-историю.\n",
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"\n",
"# Создаём новую таблицу с одним начальным snapshot-ом.\n",
"spark.sql(f\"\"\"\n",
" CREATE 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",
"practice_snapshot_count = spark.sql(f\"SELECT * FROM {DEMO_TABLE}.snapshots\").count()\n",
"assert practice_snapshot_count == 1, f\"Ожидался 1 snapshot, получено {practice_snapshot_count}\"\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? Что происходит с текущим состоянием, старыми snapshot-ами и цепочкой `parent_id`?\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`, хотя старые snapshot-ы сохраняются?\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} PURGE\")\n",
"print(\"Демо-таблица и её учебные данные удалены.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Итог модуля:**\n",
"- Schema evolution (`ADD COLUMN`) — безопасная metadata-only операция, которая мгновенно видна из всех движков через общий каталог.\n",
"- Snapshot-ы — автоматическая история изменений данных, встроенная в Iceberg.\n",
"- Time travel — чтение предыдущих состояний без бэкапов.\n",
"- Rollback — переключение текущего состояния на существующий snapshot-предок без создания нового snapshot-а.\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,816 @@
{
"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 строк). Перед созданием удалим одноразовую таблицу через `DROP TABLE ... PURGE`, чтобы при повторном запуске история снова начиналась с одного snapshot-а.\n",
"*(Внимание: 8 INSERT-ов могут занять 1-2 минуты)*\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Демо-таблица одноразовая: удаляем данные и старую snapshot-историю,\n",
"# чтобы повторный запуск дал те же 9 snapshot-ов.\n",
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"\n",
"# Создание демо-таблицы (создаст 1 файл и 1 snapshot)\n",
"spark.sql(f\"\"\"\n",
" CREATE 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-ы из метаданных и файлы, которые больше не нужны ни одному сохранённому snapshot-у. Очистка выполняется при вызове процедуры.\n",
"\n",
"Два параметра определяют, какие snapshot-ы будут удалены:\n",
"- `older_than` — граница времени: удалить можно snapshot-ы, созданные раньше неё. Без явной границы при стандартных настройках удаляются только snapshot-ы старше 5 дней.\n",
"- `retain_last` — сколько последних snapshot-ов в цепочке предков текущего состояния сохранить независимо от возраста. Это минимум, а не требование оставить ровно столько.\n",
"\n",
"Поэтому один `retain_last => 2` не удалит snapshot-ы, которые мы только что создали. Для демо возьмём текущее время Spark-сессии как `older_than` и сохраним два последних snapshot-а.\n",
"\n",
"**Важный компромисс:** time travel к удалённым snapshot-ам станет невозможен. Текущее время как границу используем только для одноразовой демо-таблицы; в production срок хранения выбирают с учётом требований к истории и восстановлению.\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",
"snapshot_count_before = snapshots_before.count()\n",
"print(f\"Snapshot-ов до expire: {snapshot_count_before}\")\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",
"row_count_before_expire = spark.table(DEMO_TABLE).count()\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Получаем границу времени в часовом поясе Spark-сессии\n",
"cutoff = spark.sql(\"\"\"\n",
" SELECT date_format(current_timestamp(), 'yyyy-MM-dd HH:mm:ss.SSSSSS') AS cutoff\n",
"\"\"\").first()[\"cutoff\"]\n",
"\n",
"# Удаляем snapshot-ы до cutoff, сохраняя два последних\n",
"spark.sql(f\"\"\"\n",
" CALL lakehouse.system.expire_snapshots(\n",
" table => 'default.taxi_maintenance_demo',\n",
" older_than => TIMESTAMP '{cutoff}',\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",
"remaining_snapshot_ids = [row[\"snapshot_id\"] for row in snapshots_after.collect()]\n",
"print(f\"Snapshot-ов: {snapshot_count_before} → {len(remaining_snapshot_ids)}\")\n",
"snapshots_after.show(truncate=False)\n",
"assert len(remaining_snapshot_ids) == 2, \"Ожидалось ровно 2 snapshot-а после очистки.\"\n",
"assert first_snapshot_id not in remaining_snapshot_ids, \"Самый старый snapshot не удалён.\"\n",
"\n",
"# Очистка истории не меняет данные в текущем состоянии\n",
"row_count_after_expire = spark.table(DEMO_TABLE).count()\n",
"assert row_count_after_expire == row_count_before_expire, \"Число строк изменилось после очистки.\"\n",
"print(f\"Данные на месте: {row_count_after_expire} строк.\")\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",
"except Exception as e:\n",
" print(f\"Ожидаемая ошибка: {e}\")\n",
" print(\"Snapshot удалён — time travel невозможен.\")\n",
"else:\n",
" raise AssertionError(\"Time travel сработал: старый snapshot не был удалён.\")\n",
"\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Вывод:** expire нужно запускать обдуманно. Слишком агрессивно (потеря страховки для rollback), слишком редко (рост метаданных и стоимости хранения).\n",
"\n",
"В нашем демо сохранены snapshot после compaction и его предок до compaction. Предку ещё нужны старые мелкие файлы, поэтому `expire_snapshots` их не удаляет. Удаление snapshot-ов не обязательно освобождает data files: важны ссылки из оставшихся состояний.\n",
"\n",
"`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 до compaction, мелкие файлы останутся: на них ссылается текущий snapshot. После compaction они будут нужны его предку. Удалить их сможет следующий вызов `expire_snapshots`, когда этот предок попадёт под условия очистки. До этого файлы не являются «сиротами».\n",
"\n",
"**Типичные ошибки:**\n",
"1. **Никогда не запускать compaction:** таблица обрастает тысячами мелких файлов, чтение деградирует в десятки раз.\n",
"2. **Удалить исправный snapshot после ошибочной записи:** если `older_than` и `retain_last` допускают его удаление, после expire откатиться к нему уже не выйдет. Правило: перед 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, затем получи свежий `cutoff` по образцу Секции 4 и вызови `expire_snapshots` с `older_than => TIMESTAMP '{cutoff}'` и `retain_last => 1`.\n",
"- Проверь результат ПОСЛЕ: остался 1 snapshot, число строк не изменилось.\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} PURGE\")\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`). После него получи свежий `cutoff` по образцу Секции 4 и вызови `expire_snapshots` с `older_than => TIMESTAMP '{cutoff}'` и `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
}
+12 -3
View File
@@ -2,14 +2,20 @@
Этот каталог хранит внутренние рабочие планы по развитию учебного курса. Этот каталог хранит внутренние рабочие планы по развитию учебного курса.
## Развитие после первой версии
[План развития курса](./course-roadmap.md) — отзывы студентов, ближайшие доработки, их порядок и ссылки на задачи в Gitea.
## Правила хранения ## Правила хранения
- Формат: `living docs`. - Формат: `living docs`.
- История изменений хранится в git. - История изменений хранится в git.
- Именование файлов: `module-NN-<english-slug-from-module-title>.md`. - Именование планов отдельных модулей: `module-NN-<english-slug-from-module-title>.md`.
- В каждом плане фиксируются `Статус` и `Последнее обновление`. - В каждом плане фиксируются `Статус` и `Последнее обновление`.
## Оглавление ## Планы разработки модулей первой версии
Ниже сохранены планы разработки восьми модулей. Их статусы отражают записи в этих документах; актуальный маршрут прохождения описан в [программе курса](../docs/course_program.md).
| Модуль | План | Статус | Целевые артефакты | Обновлён | | Модуль | План | Статус | Целевые артефакты | Обновлён |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@@ -17,4 +23,7 @@
| 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | | 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 |
| 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | | 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 |
| 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 |
| 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Ready for validation | `notebooks/05_silver_layer.ipynb` | 2026-03-07 |
| 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Ready for validation | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 |
| 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Ready for validation | `notebooks/07_safe_table_changes.ipynb` | 2026-03-08 |
| 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Ready for validation | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-08 |
+71
View File
@@ -0,0 +1,71 @@
# План развития курса «Lakehouse без магии»
Статус: ближайшие доработки завершены; ожидается обратная связь по оставшимся модулям.
Последнее обновление: 2026-09-15.
Здесь собираем отзывы студентов, выбираем следующие улучшения и объясняем их порядок. Подробные задания, критерии приёмки и актуальный статус исполнения хранятся в [Gitea Issues](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues).
Текущие рамки курса задаёт [PRD](../docs/course_prd.md), а состав уроков — [программа](../docs/course_program.md). Статус каждой работы указан ниже. При реализации изменения сначала обновляем соответствующий канонический документ, затем учебные материалы и ссылки на них.
## Обратная связь
### Базовая настройка Spark-сессии
- **Источник:** отзыв Арсения Деева, переданный автором курса и зафиксированный 2026-09-15. На момент отзыва студент дошёл до модуля 4.
- **Наблюдение студента:** курс подходит начинающим, но не хватает базовой настройки Spark-сессии без сложного тюнинга.
- **Учебный пробел:** студент использует подготовленную сессию; ему нужна практика, чтобы понять, какие настройки потребуются вне готового ноутбука.
- **Решение:** добавить короткую обязательную практику после модуля 2, перед работой с raw-данными в модуле 3. До её разработки проверить воспроизводимость создания сессий.
Отзыв относится к уже пройденной части курса. Замечания по модулям 5–8 пока не получены.
## Ближайшие работы
### 1. Проверить и исправить создание Spark-сессий
**Статус:** выполнено, [MR #3](https://git.dementev.space/ddmitry/mini-lakehouse-lab/pulls/3).
**Задача:** [#1 — подключение Spark-сессий в новом ядре](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/1).
В новых процессах модуль 1 подключался к кластеру, а модули 2–8 запускали Spark
локально (`local[*]`). В исправлении все восемь ноутбуков берут адрес кластера
из `spark/spark-defaults.conf`.
**Проверено:** инициализация каждого ноутбука в отдельном процессе,
вычисление на кластере, повторный `getOrCreate()`, запись и чтение тестовой
Iceberg-таблицы. Результаты проверок и их ограничения приведены в комментарии к #1.
### 2. Добавить практику «Как настроен Spark в нашем стенде»
**Статус:** выполнено, [MR #4](https://git.dementev.space/ddmitry/mini-lakehouse-lab/pulls/4).
**Задача:** [#2 — практика базовой настройки SparkSession](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/2).
В модуле 3 студент готовит Spark-сессию, проверяет подключение и затем переходит к загрузке и чтению Taxi. Содержание и checkpoint описаны в [программе](../docs/course_program.md#модуль-3-raw-данные-и-первое-чтение-в-spark).
Код проверен на стенде, включая перезапуск ядра и полный raw-ingest. Замеры и ограничения проверки сохранены в [отчете](issue-2-spark-session-validation.md).
### 3. Собрать замечания по оставшейся части курса
**Статус:** ожидается продолжение обратной связи.
По мере прохождения Арсением остальных модулей добавлять конкретные наблюдения в этот документ. После полного прохода выбрать следующие доработки с учётом повторяющихся затруднений, самостоятельности студента и общей длительности курса.
Новые issues создавать, когда понятны проблема, ожидаемый результат и способ его проверки.
## Отложенные идеи
- Небольшой дополнительный опыт с числом партиций и наблюдением выполнения в Spark UI. Вернуться к нему после проверки объёма базовой практики; потребуется объяснить распределённое выполнение.
- Другие возможные расширения перечислены в [разделе 7 программы](../docs/course_program.md#7-что-не-входит-в-первую-версию-курса). Их включение и порядок пока не определены.
Номер и дата следующего релиза пока не назначены.
## Как обновлять этот план
Для нового предложения записывать: источник и этап прохождения → наблюдение → учебный пробел → предлагаемое изменение → проверяемый результат → статус и ссылка на issue.
После реализации добавлять ссылку на итог задачи и кратко отмечать, что изменилось для студента. Подробные критерии и ход исполнения сохранять в issue.
## Выпущенные улучшения
- [#1](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/1): все восемь ноутбуков подключаются к Spark-кластеру при создании сессии в новом процессе; настройки собраны в `spark/spark-defaults.conf`.
- [#2](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/2): модуль 3 включает самостоятельное подключение к Spark перед загрузкой и чтением Taxi.
+95
View File
@@ -0,0 +1,95 @@
# Проверка практики SparkSession в модуле 3
Дата: 2026-09-15.
Задача: https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/2.
База: `00942c2`, результат закрытой [#1](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/1) прочитан вместе с комментариями.
## Что проверялось
Фактические ячейки `notebooks/03_raw_ingest_and_first_read.ipynb` выполнялись
через `jupyter_client.KernelManager` в контейнере `jupyter`, Spark 3.5.1.
Менеджер запускал настоящее IPython-ядро и перезапускал его через
`restart_kernel(now=True)`. Проверка не подменяла создание сессии общим helper-ом.
Порядок воспроизведения:
1. Поднять стенд и получить стандартный bundle по `START_HERE.md`.
2. В новом ядре выполнить демонстрацию, вывод настроек, опыт с часовым поясом,
повторный builder и остановку сессии.
3. Заполнить три пропуска шаблона, выполнить его дважды и сравнить контекст
и applicationId. Выполнить опубликованное контрольное решение
(при проверке извлекалось из блока `session-solution`).
4. Выполнить проверку master/appName и дважды создать, прочитать и удалить
временную таблицу.
5. Перезапустить ядро. Убедиться, что переменные `spark` и `CLUSTER_MASTER`
отсутствуют; выполнить контрольное решение, проверку подключения и таблицы.
6. Продолжить исходный raw-ingest до `spark.stop()`, заполнив самостоятельные
упражнения с lookup, top-5 локаций и ещё одной аномалией.
7. Отдельно проверить заполненный шаблон в новом ядре и повторную загрузку
уже существующих raw-файлов.
## Результаты
| Проверка | Результат |
| --- | --- |
| Демонстрационная сессия | Master `spark://spark-master:7077`, appName `module-03-session-demo`, сумма 0…9 = 45 |
| Настройки каталога | `SparkCatalog`, `JdbcCatalog`, URI PostgreSQL и warehouse соответствуют общему файлу; Hadoop endpoint `http://minio:9000`, path-style `true` |
| Изменяемая настройка | `spark.sql.session.timeZone` допускает изменение; Unix-время 0 отображается как `1970-01-01 00:00:00` в UTC и `1970-01-01 03:00:00` в Москве |
| Восстановление | В `finally` восстановлено исходное `Etc/UTC`, проверено через assert |
| Повторный builder с другим appName | Тот же объект сессии и контекста, фактическое имя остаётся `module-03-session-demo` |
| Создание после stop | Новое имя `module-03-raw-ingest-and-first-read`, master кластерный |
| Повторение шаблона и контрольного решения | Контекст и applicationId не меняются |
| Заполненный шаблон в отдельном новом ядре | Master и appName верны, распределённая сумма 0…9 = 45 |
| Перезапуск ядра | Переменные исчезли, сессия создана заново; ID изменился с `app-20260915125347-0001` на `app-20260915125511-0002` |
| Iceberg до и после перезапуска | Три независимые таблицы с уникальными именами; каждый раз прочитаны `[0, 1]`, после PURGE таблица отсутствует в каталоге |
| Raw-ingest | Январь: 2 964 624 строки; январь–март: 9 554 778 строк, 19 колонок |
| Профилирование | Схема, null-доли, describe и четыре проверки аномалий выполнены без ошибок |
| Самостоятельное задание raw | Lookup: 265 строк; подсчёт зон по Borough и top-5 pickup с названиями зон выполнены; найдено 180 поездок с отрицательной длительностью |
| Повторная загрузка raw в отдельном ядре | Все четыре файла пропущены с `skipped_same_size`; снимки всех объектов и каталога до/после совпали |
Код исходного raw-ingest после ячейки подготовки не изменён, что проверено
сравнением ячеек с базовым коммитом. Из подготовки убрано только создание сессии:
она теперь приходит из новой практики.
## Время и ограничения
- Машинный прогон блока настройки: **179,27 с**. Сюда вошли дополнительные
повторы шаблона и таблицы, а также настоящий перезапуск ядра.
- Исходная часть модуля, включая заполненные самостоятельные упражнения:
**70,41 с**. Полный основной прогон: **250,02 с** (около 4 минут 10 секунд).
- Запуск контейнеров и скачивание bundle в замер не входят. Это время выполнения
кода; чтение объяснений, самостоятельное заполнение и ответы студента не измерялись.
- Ориентир новой практики **20–30 минут**, модуля целиком **около 2 часов**.
Общую оценку **12–15 часов** оставили предварительной; машинного замера
недостаточно для её уточнения. Нужен самостоятельный проход студента.
- Кнопки Jupyter/VS Code, отображение раскрывающегося решения и UI сервисов
не проверялись. Перезапуск выполнен менеджером ядра через API, а не кнопкой.
- Остальные семь ноутбуков и Trino smoke не выполнялись: runtime-конфигурация
не менялась, проверялся модуль 3.
## Сохранность данных
До основного прогона в каталоге было **0 таблиц**, в bucket — **4 объекта**.
После прогона исходные объекты сохранили ETag и размер, состав каталога
не изменился. Это проверка на имевшемся состоянии стенда, а не на копии
заполненного студентом курса.
Добавлены четыре исходных файла Taxi в `raw/nyc_taxi/`, как предусмотрено уроком;
они оставлены для модуля 4. Локальный bundle сохранён в игнорируемом `data/`.
Файлы данных и метаданных трёх временных таблиц удалены через PURGE.
Hadoop S3A оставил шесть нулевых маркеров `data/` и `metadata/`;
при завершении проверки удалены только эти шесть зафиксированных объектов.
В трёх префиксах практики осталось 0 объектов. Volumes не сбрасывались.
Поднятый для проверки стенд остановлен через `docker compose down` без `-v`.
## Статические проверки
- `git diff --check`;
- `docker compose config --quiet`;
- проверка ноутбука через `nbformat.validate` и синтаксиса Python-ячеек через AST;
- относительные ссылки изменённых документов и ноутбука;
- сохранение восьми модулей и маршрута README → START_HERE → программа → ноутбук.
После редакторской правки по ревью повторно проверены ссылки, нумерация разделов
и синтаксис Python. Все исполняемые ячейки, их порядок и контрольное решение
совпадают с проверенной версией. Стенд повторно не запускался.
+2 -2
View File
@@ -1,7 +1,7 @@
# Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute # Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute
**Статус:** `Ready for validation` **Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-07` **Последнее обновление:** `2026-09-15`
## Цель ## Цель
@@ -33,7 +33,7 @@
- запрос к PostgreSQL (каталогу метаданных), чтобы увидеть регистрацию таблицы; - запрос к PostgreSQL (каталогу метаданных), чтобы увидеть регистрацию таблицы;
- запрос к MinIO (например, s3a list или boto3), чтобы увидеть появление файлов данных (parquet) и метаданных (json/avro). - запрос к MinIO (например, s3a list или boto3), чтобы увидеть появление файлов данных (parquet) и метаданных (json/avro).
5. Сформулировать самостоятельное задание: студент создает свою таблицу, пишет в нее данные и находит её следы в хранилище и каталоге. 5. Сформулировать самостоятельное задание: студент создает свою таблицу, пишет в нее данные и находит её следы в хранилище и каталоге.
6. Включить в конец ноутбука обязательный шаг очистки (cleanup) — удаление таблиц и namespace `module_02`, чтобы не оставлять мусор для следующих модулей. 6. Включить в конец ноутбука обязательный шаг очистки (cleanup) — удаление одноразовых таблиц через `DROP TABLE ... PURGE` и namespace `module_02`, чтобы не оставлять данные и метаданные для следующих модулей.
7. Описать вопросы для Checkpoint. 7. Описать вопросы для Checkpoint.
## Checkpoint ## Checkpoint
+44 -24
View File
@@ -1,16 +1,22 @@
# Модуль 3. Raw-данные и первое чтение в Spark # Модуль 3. Raw-данные и первое чтение в Spark
**Статус:** `Ready for validation` **Статус:** `Done`
**Последнее обновление:** `2026-03-07` **Последнее обновление:** `2026-09-15`
**Задача:** [#2](https://git.dementev.space/ddmitry/mini-lakehouse-lab/issues/2).
Результаты прогона и ограничения: [проверка #2](issue-2-spark-session-validation.md).
## Цель ## Цель
Научить студента полному циклу работы с raw-данными в Lakehouse: доставка заранее скачанного PARQUET-датасета в raw-зону MinIO, чтение raw-данных через Spark, проверка схемы, типов и базового качества. Студент должен понять, зачем нужен raw-слой и почему raw-данные не следует «чинить на месте». Научить студента подключаться к Spark-кластеру и работать с raw-данными: загружать заранее скачанный PARQUET-датасет в MinIO, читать его через Spark и проверять схему, типы и базовое качество. Студент должен понять, зачем нужен raw-слой и почему raw-данные не следует «чинить на месте».
## Результат для студента ## Результат для студента
После прохождения модуля студент: После прохождения модуля студент:
- создает сессию для кластера с подготовленным каталогом, проверяет настройки и понимает их источники;
- различает повторный вызов builder-а, остановку сессии и перезапуск ядра;
- понимает, как локальный data bundle попадает в raw-зону MinIO; - понимает, как локальный data bundle попадает в raw-зону MinIO;
- умеет загружать PARQUET-файлы в MinIO программно через `boto3`; - умеет загружать PARQUET-файлы в MinIO программно через `boto3`;
- умеет читать raw-данные из MinIO через Spark (`spark.read.parquet` по `s3a://` пути); - умеет читать raw-данные из MinIO через Spark (`spark.read.parquet` по `s3a://` пути);
@@ -81,61 +87,73 @@ volumes:
## Структура ноутбука ## Структура ноутбука
### Секция 0: Введение ### Секция 0: Перед стартом
- **[md]** Заголовок, цели модуля, prerequisite (data bundle из `START_HERE.md`). Таблица-сравнение: «Классический DWH: данные уже внутри СУБД» vs «Lakehouse: данные нужно явно доставить в storage». - **[md]** Связь с модулем 2, цели работы с Taxi и условия старта: работающий стенд, скачанный data bundle, новое ядро.
### Секция 1: Проверка data bundle ### Секция 1: Как настроен Spark в нашем стенде
- **[md]** SparkSession, SparkContext, driver и executors, local и кластер; источники настроек и подготовленный каталог.
- **[code]** Создание сессии, фактический master и несекретные настройки. Изменение часового пояса с восстановлением. Повторный builder сохраняет контекст; `stop()` позволяет создать приложение с новым именем.
### Секция 2: Подключись к Spark самостоятельно
- **[code]** Шаблон подключения и контрольное решение. Проверка master и appName, запись и чтение `[0, 1]` из собственной временной таблицы, удаление в `finally`.
- **[md]** После удаления таблицы перезапустить ядро, повторить заполненный шаблон и проверки. Оставить сессию для чтения Taxi.
### Секция 3: Проверка data bundle
- **[code]** Импорты, MinIO-клиент, пути и вспомогательные функции для работы с файлами.
- **[md]** Зачем проверяем; почему не скачиваем из интернета в ноутбуке (воспроизводимость, сетевые ограничения, разделение ответственности onboarding vs практика). - **[md]** Зачем проверяем; почему не скачиваем из интернета в ноутбуке (воспроизводимость, сетевые ограничения, разделение ответственности onboarding vs практика).
- **[code]** `os.listdir("/opt/data/nyc_taxi")`, вывод файлов с размерами, assert на наличие PARQUET-файлов и `taxi_zone_lookup.csv` — оба нужны для полного прохождения модуля (включая самостоятельное задание в Секции 6). Понятное сообщение с отсылкой к `START_HERE.md` при отсутствии любого из компонентов. - **[code]** `os.listdir("/opt/data/nyc_taxi")`, вывод файлов с размерами, assert на наличие PARQUET-файлов и `taxi_zone_lookup.csv` — оба нужны для полного прохождения модуля (включая самостоятельное задание в Секции 8). Понятное сообщение с отсылкой к `START_HERE.md` при отсутствии любого из компонентов.
### Секция 2: Загрузка в raw-зону MinIO ### Секция 4: Загрузка в raw-зону MinIO
- **[md]** Что такое raw-зона, параллель с stg-слоем в Greenplum, конвенция путей, почему raw не чинится «на месте». - **[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.head_object()`, существует ли уже объект с таким ключом. Три ветки: (1) объект не существует — загружаем через `boto3.upload_file()`; (2) объект существует и размер совпадает с локальным файлом — пропускаем с пометкой «already exists, skipping»; (3) объект существует, но размер отличается — **fail fast** с понятной ошибкой (raise), без перезаписи. Это обеспечивает идемпотентность и подкрепляет принцип «raw не перезаписывается». В markdown-пояснении перед ячейкой явно объяснить: повторный запуск безопасен, уже загруженные файлы не перетираются, а конфликт размеров сигнализирует о проблеме с данными.
- **[code]** Верификация: `s3.list_objects_v2()` — листинг raw-зоны. - **[code]** Верификация: `s3.list_objects_v2()` — листинг raw-зоны.
- **[md]** Данные теперь в storage. Spark может читать их напрямую по `s3a://` пути; для Trino потребуется зарегистрированная таблица (это будет в Модулях 4 и 6). - **[md]** Данные теперь в storage. Spark может читать их напрямую по `s3a://` пути; для Trino потребуется зарегистрированная таблица (это будет в Модулях 4 и 6).
### Секция 3: Первое чтение в Spark ### Секция 5: Первое чтение в Spark
- **[md]** Разница `spark.read.parquet("s3a://...")` (raw-файлы) vs `spark.table(...)` (управляемая Iceberg-таблица). Мы пока на этапе raw, Iceberg-таблица появится в Модуле 4. - **[md]** Разница `spark.read.parquet("s3a://...")` (raw-файлы) vs `spark.table(...)` (управляемая Iceberg-таблица). Проверочная таблица удалена; таблицу поездок создаем в Модуле 4.
- **[code]** Создание SparkSession (паттерн из Module 2 — без `.master()`, `setLogLevel("ERROR")`). - **[code]** Чтение через сессию, созданную в секции 2; повторная инициализация не нужна.
- **[code]** Чтение одного файла: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_2024-01.parquet")`. - **[code]** Чтение одного файла: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_2024-01.parquet")`.
- **[code]** Чтение всех файлов wildcard: `yellow_tripdata_*.parquet` (без привязки к конкретному году в wildcard, чтобы код работал с любыми скачанными файлами). - **[code]** Чтение всех файлов wildcard: `yellow_tripdata_*.parquet` (без привязки к конкретному году в wildcard, чтобы код работал с любыми скачанными файлами).
### Секция 4: Проверка схемы ### Секция 6: Проверка схемы
- **[md]** Зачем проверять схему: в классическом DWH схема задана DDL, в raw — вычитывается из файлов. Что может пойти не так: разные типы в разных файлах, неожиданные nullable-поля. - **[md]** Зачем проверять схему: в классическом DWH схема задана DDL, в raw — вычитывается из файлов. Что может пойти не так: разные типы в разных файлах, неожиданные nullable-поля.
- **[code]** `.printSchema()`. - **[code]** `.printSchema()`.
- **[code]** Колонки и типы в удобном формате (`.dtypes`). - **[code]** Колонки и типы в удобном формате (`.dtypes`).
- **[code]** Null-анализ ключевых колонок (`VendorID`, `tpep_pickup_datetime`, `passenger_count`, `trip_distance`, `total_amount`) — количество и доля null в каждой. - **[code]** Null-анализ ключевых колонок (`VendorID`, `tpep_pickup_datetime`, `passenger_count`, `trip_distance`, `total_amount`) — количество и доля null в каждой.
- **[md]** Обсуждение: null в raw — норма, не исправляем на месте. Очистка — задача bronze/silver слоев (Модули 4-5). - **[md]** Обсуждение: null в raw — норма, не исправляем на месте. Очистка — задача bronze/silver слоев (Модули 4-5).
### Секция 5: Базовые метрики и аномалии ### Секция 7: Базовые метрики и аномалии
- **[md]** Цель: быстро понять диапазоны значений, выявить очевидные проблемы. - **[md]** Цель: быстро понять диапазоны значений, выявить очевидные проблемы.
- **[code]** `.describe()` на числовых колонках. - **[code]** `.describe()` на числовых колонках.
- **[code]** Поиск аномалий: отрицательные `fare_amount`, нулевая дистанция с ненулевой суммой, `total_amount > $1000`, pickup за пределами ожидаемого диапазона дат (определяется динамически из имён файлов, а не хардкодится). - **[code]** Поиск аномалий: отрицательные `fare_amount`, нулевая дистанция с ненулевой суммой, `total_amount > $1000`, pickup за пределами ожидаемого диапазона дат (определяется динамически из имён файлов, а не хардкодится).
- **[md]** Аномалии в реальных данных — норма, а не баг загрузки. Задача raw — сохранить как есть. - **[md]** Аномалии в реальных данных — норма, а не баг загрузки. Задача raw — сохранить как есть.
### Секция 6: Самостоятельное задание ### Секция 8: Самостоятельное задание
- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны через Spark, (2) вывести схему + 10 строк, (3) посчитать количество зон по borough, (4) найти топ-5 pickup locations и join с зонами, (5) найти ещё одну аномалию или интересный паттерн в данных. - **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны через Spark, (2) вывести схему + 10 строк, (3) посчитать количество зон по borough, (4) найти топ-5 pickup locations и join с зонами, (5) найти ещё одну аномалию или интересный паттерн в данных.
- **[code]** 4 пустых ячейки с `# Ваш код: ...`. - **[code]** 4 пустых ячейки с `# Ваш код: ...`.
### Секция 7: Почему raw-слой важен ### Секция 9: Почему raw-слой важен
- **[md]** Мини-лекция (3-5 абзацев): raw = неизменяемый архив; параллель с stg/ods в Greenplum; почему не скачиваем в ноутбуке; почему не чиним на месте (можно перестроить bronze/silver заново); в Lakehouse raw-файлы лежат в storage отдельно от compute — Spark читает их напрямую, а для Trino потребуется оформить данные в таблицу. - **[md]** Мини-лекция (3-5 абзацев): raw = неизменяемый архив; параллель с stg/ods в Greenplum; почему не скачиваем в ноутбуке; почему не чиним на месте (можно перестроить bronze/silver заново); в Lakehouse raw-файлы лежат в storage отдельно от compute — Spark читает их напрямую, а для Trino потребуется оформить данные в таблицу.
### Секция 8: Checkpoint ### Секция 10: Checkpoint
- **[md]** Вопросы для самопроверки: (1) показать файлы в raw-зоне MinIO, (2) какая схема у Yellow Taxi — сколько колонок, (3) какие колонки содержат null — это баг или норма, (4) зачем raw-слой, если данные потом трансформируются, (5) почему не скачиваем из ноутбука, (6) что произойдёт, если NYC TLC изменит формат файла. - **[md]** Вопросы об источниках настроек, месте выполнения заданий и новом ядре; затем самопроверка по данным: (1) показать файлы в raw-зоне MinIO, (2) какая схема у Yellow Taxi — сколько колонок, (3) какие колонки содержат null — это баг или норма, (4) зачем raw-слой, если данные потом трансформируются, (5) почему не скачиваем из ноутбука, (6) что произойдёт, если NYC TLC изменит формат файла.
### Секция 9: Завершение ### Секция 11: Завершение
- **[md]** «Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 мы будем использовать их для создания bronze-слоя.» - **[md]** «Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 мы будем использовать их для создания bronze-слоя.»
- **[code]** `spark.stop()`. - **[code]** `spark.stop()`.
### Дизайн-решения по ноутбуку ### Дизайн-решения по ноутбуку
- **Helper-код:** весь код inline в ноутбуке. Отдельный модуль в `src/` не создаётся — студент должен видеть каждый шаг явно (`boto3.upload_file()`, `spark.read.parquet()`, `.printSchema()`). - **Helper-код:** весь код inline в ноутбуке. Отдельный модуль в `src/` не создаётся — студент должен видеть каждый шаг явно (`boto3.upload_file()`, `spark.read.parquet()`, `.printSchema()`).
- **Cleanup:** ноутбук НЕ удаляет raw-данные из MinIO — они нужны Модулю 4 (bronze). Spark-сессия останавливается. Явное пояснение в конце ноутбука. - **Cleanup:** практика настройки удаляет только свою временную таблицу. Ноутбук НЕ удаляет raw-данные из MinIO — они нужны Модулю 4 (bronze). Spark-сессия останавливается в конце.
## Checkpoint ## Checkpoint
Студент должен уметь: Студент должен уметь:
- создать сессию по заполненному шаблону, подтвердить master `spark://spark-master:7077` и заданное appName;
- прочитать `[0, 1]` из собственной Iceberg-таблицы и подтвердить её удаление;
- объяснить, какие настройки предоставил стенд, где выполняются задания и что повторить после перезапуска ядра;
- показать файлы в raw-зоне MinIO (через MinIO Console или через код); - показать файлы в raw-зоне MinIO (через MinIO Console или через код);
- прочитать raw PARQUET через Spark и вывести несколько строк; - прочитать raw PARQUET через Spark и вывести несколько строк;
- назвать схему Yellow Taxi (количество колонок, основные типы); - назвать схему Yellow Taxi (количество колонок, основные типы);
@@ -148,10 +166,11 @@ volumes:
- `docker-compose.yml` содержит volume mount `./data:/opt/data:ro` для контейнера `jupyter`, и `docker compose config` проходит без ошибок. - `docker-compose.yml` содержит volume mount `./data:/opt/data:ro` для контейнера `jupyter`, и `docker compose config` проходит без ошибок.
- `START_HERE.md` содержит воспроизводимую инструкцию по скачиванию NYC Taxi PARQUET (минимум 3 файла + taxi zone lookup) до запуска Модуля 3. - `START_HERE.md` содержит воспроизводимую инструкцию по скачиванию NYC Taxi PARQUET (минимум 3 файла + taxi zone lookup) до запуска Модуля 3.
- `.gitignore` содержит `/data/` для исключения датасета из git. - `.gitignore` содержит `/data/` для исключения датасета из git.
- `notebooks/03_raw_ingest_and_first_read.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении при наличии скачанного data bundle. - `notebooks/03_raw_ingest_and_first_read.ipynb` можно пройти по порядку в поднятом Jupyter-окружении при наличии data bundle, заполнив шаблон и выполнив указанный перезапуск ядра.
- Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. - Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
- Ноутбук использует паттерны из Модулей 1-2: `boto3` для MinIO, SparkSession без `.master()`, `setLogLevel("ERROR")`. - Демонстрация берёт master из `spark-defaults.conf`; самостоятельное задание явно задаёт тот же адрес, сохраняя подготовленную конфигурацию каталога. Шаблон и контрольное решение проверяются в новых ядрах на Spark 3.5.1.
- Raw-данные остаются в MinIO после выполнения ноутбука (cleanup отсутствует намеренно). - Проверены повторные ячейки, перезапуск ядра, изменение и восстановление часового пояса, новое имя приложения после остановки контекста.
- Raw-данные остаются в MinIO после выполнения ноутбука; удаляется только временная таблица настройки.
- При отсутствии data bundle — понятный assert с отсылкой к `START_HERE.md`. - При отсутствии data bundle — понятный assert с отсылкой к `START_HERE.md`.
- `docs/stack_reference.md` обновлён: секция JupyterLab содержит `./data` mount. - `docs/stack_reference.md` обновлён: секция JupyterLab содержит `./data` mount.
- `plans/README.md` содержит строку Модуля 3 в оглавлении. - `plans/README.md` содержит строку Модуля 3 в оглавлении.
@@ -159,14 +178,15 @@ volumes:
## Риски ## Риски
- **Размер данных.** 3 месяца Yellow Taxi — примерно 120-150 MB (~40-50 MB на файл). Комфортно для стенда с 10 GB RAM. Extended (12 месяцев, ~600 MB) может замедлить Spark, но не должен вызывать падения. - **Размер данных.** 3 месяца Yellow Taxi — примерно 120-150 MB (~40-50 MB на файл). Комфортно для стенда с 10 GB RAM. Extended (12 месяцев, ~600 MB) может замедлить Spark, но не должен вызывать падения.
- **Студент забыл скачать данные.** Assert в Секции 1 с понятным сообщением и ссылкой на `START_HERE.md`. - **Студент забыл скачать данные.** Assert в Секции 3 с понятным сообщением и ссылкой на `START_HERE.md`.
- **NYC TLC изменит формат/URL.** Ноутбук использует wildcard `yellow_tripdata_*.parquet` без привязки к году. Инструкция в `START_HERE.md` фиксирует конкретные файлы 2024-01..03, но если URL станут недоступны — потребуется обновление инструкции. - **NYC TLC изменит формат/URL.** Ноутбук использует wildcard `yellow_tripdata_*.parquet` без привязки к году. Инструкция в `START_HERE.md` фиксирует конкретные файлы 2024-01..03, но если URL станут недоступны — потребуется обновление инструкции.
- **Директория `data/` не существует.** Docker volume mount создаст пустую директорию, assert в ноутбуке поймает отсутствие файлов. - **Директория `data/` не существует.** Docker volume mount создаст пустую директорию, assert в ноутбуке поймает отсутствие файлов.
- **Пересоздание контейнера.** Добавление volume в `docker-compose.yml` требует пересоздания контейнера `jupyter` (`docker compose up -d` достаточно — Docker пересоздаст только изменённый сервис). После этого bind mount уже работает: новые файлы в `./data` видны сразу без дополнительных перезапусков. Указать явно в инструкции. - **Пересоздание контейнера.** Добавление volume в `docker-compose.yml` требует пересоздания контейнера `jupyter` (`docker compose up -d` достаточно — Docker пересоздаст только изменённый сервис). После этого bind mount уже работает: новые файлы в `./data` видны сразу без дополнительных перезапусков. Указать явно в инструкции.
## Out of Scope ## Out of Scope
- Создание Iceberg-таблиц — отложено до Модуля 4. - Создание рабочих Iceberg-таблиц из raw — в Модуле 4. В Модуле 3 есть только маленькая временная таблица для проверки подключения.
- Подбор памяти, числа executors, shuffle, GC и эксперимент с числом партиций.
- Трансформации и очистка данных — отложено до Модулей 4-5. - Трансформации и очистка данных — отложено до Модулей 4-5.
- Чтение raw-данных через Trino (Trino не умеет читать произвольные файлы без таблицы) — отложено до Модуля 6. - Чтение raw-данных через Trino (Trino не умеет читать произвольные файлы без таблицы) — отложено до Модуля 6.
- Партиционирование, MERGE, streaming, gold-слой. - Партиционирование, MERGE, streaming, gold-слой.
+22 -16
View File
@@ -1,7 +1,7 @@
# Модуль 4. Первая рабочая Iceberg-таблица и слой bronze # Модуль 4. Первая рабочая Iceberg-таблица и слой bronze
**Статус:** `Draft` **Статус:** `Draft`
**Последнее обновление:** `2026-03-07` **Последнее обновление:** `2026-09-15`
## Цель ## Цель
@@ -50,11 +50,12 @@
### Создание и наполнение таблицы: CTAS с явным списком колонок ### Создание и наполнение таблицы: CTAS с явным списком колонок
Используем `CREATE OR REPLACE TABLE ... USING iceberg AS SELECT col1, col2, ... FROM raw_view` (CTAS). Это: Используем `CREATE OR REPLACE TABLE ... USING iceberg AS SELECT col1, col2, ... FROM raw_view` (CTAS). В настроенном `SparkCatalog` это атомарная замена. Она:
- объединяет создание и наполнение в один шаг — понятнее и ближе к `CREATE TABLE AS SELECT` в PostgreSQL; - объединяет создание и наполнение в один шаг — понятнее и ближе к `CREATE TABLE AS SELECT` в PostgreSQL;
- явный список колонок в SELECT исключает ошибки из-за несовпадения порядка или состава колонок (PRD Risk #3, #5); - явный список колонок в SELECT исключает ошибки из-за несовпадения порядка или состава колонок (PRD Risk #3, #5);
- `CREATE OR REPLACE` обеспечивает идемпотентность: повторный запуск пересоздаёт таблицу, snapshot history остаётся чистой; - повторный запуск приводит текущее содержимое к тому же результату и не дублирует строки;
- trade-off: `CREATE OR REPLACE` уничтожает историю — в production это опасно, но для первичной загрузки учебного bronze допустимо. В Модуле 7 студент увидит, почему в рабочих сценариях нужен другой подход. - создаёт новый snapshot и сохраняет прежние snapshot-ы для time travel;
- заменяет текущие данные, схему и partition spec; новый snapshot не имеет `parent_id`, поэтому прежние snapshot-ы перестают быть предками текущего состояния. Отдельная именованная ветка при этом не создаётся.
В Секции 2 DDL с явной схемой показывается как справочный пример (не выполняется) — чтобы студент увидел знакомую форму `CREATE TABLE (col type, ...)`. В Секции 2 DDL с явной схемой показывается как справочный пример (не выполняется) — чтобы студент увидел знакомую форму `CREATE TABLE (col type, ...)`.
@@ -70,9 +71,9 @@
Bronze нужен Модулю 5. Spark-сессия останавливается. Явное пояснение в финале ноутбука: «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver на основе bronze.» Bronze нужен Модулю 5. Spark-сессия останавливается. Явное пояснение в финале ноутбука: «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver на основе bronze.»
### Taxi zone lookup: самостоятельное задание ### Taxi zone lookup: демо-секция
Студент создаёт `lakehouse.bronze.taxi_zone_lookup` как упражнение. Это подготовка к join в Модуле 5 (silver). Создание `lakehouse.bronze.taxi_zone_lookup` входит в демонстрационный код ноутбука (Секция 8), а не в самостоятельное задание. Причина: Модуль 5 использует lookup для JOIN в демо-коде — если lookup не создан, студент не может пройти Модуль 5, даже выполняя только готовые ячейки. Принцип: демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих.
## План работ ## План работ
@@ -93,7 +94,7 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
| История изменений | Нет (или WAL на уровне СУБД) | Snapshots и manifests (встроены в формат) | | История изменений | Нет (или WAL на уровне СУБД) | Snapshots и manifests (встроены в формат) |
### Секция 1: Spark-сессия и проверка raw ### Секция 1: Spark-сессия и проверка raw
- **[code]** SparkSession (паттерн Модулей 2-3: без `.master()`, `setLogLevel("ERROR")`). - **[code]** SparkSession (паттерн Модулей 2-3: адрес кластера из `spark-defaults.conf`, `setLogLevel("ERROR")`).
- **[code]** Быстрая проверка: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet").count()` + `.printSchema()`. Assert на ненулевой count с отсылкой к Модулю 3. - **[code]** Быстрая проверка: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet").count()` + `.printSchema()`. Assert на ненулевой count с отсылкой к Модулю 3.
- **[md]** Сейчас читаем как набор файлов — нет истории, каталогизации, контроля схемы. Дальше создадим управляемую таблицу. - **[md]** Сейчас читаем как набор файлов — нет истории, каталогизации, контроля схемы. Дальше создадим управляемую таблицу.
@@ -105,7 +106,7 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
### Секция 3: Загрузка raw → bronze (CTAS) ### Секция 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 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 увидим, почему в рабочих сценариях нужен другой подход. - **[md]** Повторяемость: `CREATE OR REPLACE` при повторном запуске атомарно заменяет текущее содержимое и не дублирует строки, но добавляет новый snapshot. Прежние snapshot-ы сохраняются для time travel и перестают быть предками текущего состояния. Trade-off: операция может заменить схему и partition spec, поэтому для учебной полной загрузки она удобна, а в рабочих инкрементальных сценариях требует осторожности. В Модуле 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]** Чтение 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 — должны совпасть. - **[code]** Верификация: count bronze vs count raw — должны совпасть.
@@ -160,11 +161,16 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
- Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw. - Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw.
- В Модуле 5 — silver: очистка, трансформации, бизнес-логика. - В Модуле 5 — silver: очистка, трансформации, бизнес-логика.
### Секция 7: Самостоятельное задание ### Секция 7: Вторая bronze-таблица: taxi_zone_lookup
- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны, (2) создать `lakehouse.bronze.taxi_zone_lookup` с DDL (LocationID INT, Borough STRING, Zone STRING, service_zone STRING), (3) загрузить данные, (4) проверить через `spark.table(...)`, (5) осмотреть snapshots. - **[md]** В raw-зоне кроме Yellow Taxi лежит `taxi_zone_lookup.csv` — справочник зон. В Модуле 5 он понадобится для JOIN. Загружаем в bronze по тому же паттерну CTAS, но из CSV.
- **[code]** 5 пустых ячеек `# Ваш код: ...`. - **[code]** Чтение CSV через `spark.read.option("header", "true").option("inferSchema", "true").csv(...)`. Вывод count, schema, preview.
- **[code]** CTAS: `CREATE OR REPLACE TABLE lakehouse.bronze.taxi_zone_lookup USING iceberg AS SELECT CAST(LocationID AS INT) AS LocationID, Borough, Zone, service_zone FROM raw_zone_lookup`. Верификация: count, show.
### Секция 8: Checkpoint ### Секция 8: Самостоятельное задание
- **[md]** Инструкция: осмотреть внутреннюю структуру `taxi_zone_lookup` теми же инструментами, что в Секциях 5 и 6. (1) Вывести snapshots и files. (2) Сравнить количество data files с `nyc_taxi_yellow` и объяснить разницу.
- **[code]** 2 пустых ячейки `# Ваш код: ...`.
### Секция 9: Checkpoint
- **[md]** Вопросы для самопроверки: - **[md]** Вопросы для самопроверки:
1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`? 1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`?
2. Что хранит snapshot и зачем он нужен? 2. Что хранит snapshot и зачем он нужен?
@@ -173,8 +179,8 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata? 5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata?
6. Можете ли вы показать таблицу в MinIO Console (через браузер)? 6. Можете ли вы показать таблицу в MinIO Console (через браузер)?
### Секция 9: Завершение ### Секция 10: Завершение
- **[md]** «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver-слой на основе `lakehouse.bronze.nyc_taxi_yellow`.» - **[md]** «Мы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой.»
- **[code]** `spark.stop()`. - **[code]** `spark.stop()`.
### Дизайн-решения по ноутбуку ### Дизайн-решения по ноутбуку
@@ -197,7 +203,7 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
- `notebooks/04_bronze_with_iceberg.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 3 (raw-данные в MinIO). - `notebooks/04_bronze_with_iceberg.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 3 (raw-данные в MinIO).
- Ноутбук следует методике курса: `объяснение → демонстрация → самостоятельное повторение → checkpoint`. - Ноутбук следует методике курса: `объяснение → демонстрация → самостоятельное повторение → checkpoint`.
- Ноутбук использует паттерны из Модулей 1-3: SparkSession без `.master()`, `setLogLevel("ERROR")`, `boto3` для MinIO. - Ноутбук использует паттерны из Модулей 1-3: SparkSession с адресом кластера из `spark-defaults.conf`, `setLogLevel("ERROR")`, `boto3` для MinIO.
- Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки. - Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки.
- CTAS использует явный список колонок, а не `SELECT *`. - CTAS использует явный список колонок, а не `SELECT *`.
- Bronze-таблица `lakehouse.bronze.nyc_taxi_yellow` остаётся после выполнения ноутбука для использования в Модуле 5. - Bronze-таблица `lakehouse.bronze.nyc_taxi_yellow` остаётся после выполнения ноутбука для использования в Модуле 5.
@@ -211,7 +217,7 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
- **Схема raw PARQUET.** Фиксируем колонки NYC Taxi 2024 в DDL. Если студент скачал другой год — DDL может не совпасть. Пояснение: придерживаться инструкции из `START_HERE.md`. - **Схема 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. Проверить на стенде при реализации. - **Column case sensitivity.** NYC Taxi PARQUET использует mixed case (`VendorID`, `PULocationID`). Iceberg хранит имена case-sensitive. DDL должен совпадать с именами в PARQUET. Проверить на стенде при реализации.
- **Мелкие файлы.** Spark может создать много мелких data files (по числу partitions). Нормально для Module 4 — тема compaction отложена до Модуля 8. Упомянуть, не решать. - **Мелкие файлы.** 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. - **Повторный запуск.** Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS): текущее содержимое заменяется атомарно и строки не дублируются. При каждом запуске появляется новый snapshot; предыдущие snapshot-ы сохраняются для time travel, но перестают быть предками текущего. В markdown явно объяснить этот trade-off и риск замены схемы и partition spec.
## Out of Scope ## Out of Scope
+10 -10
View File
@@ -1,7 +1,7 @@
# Модуль 5. Слой silver и воспроизводимые трансформации # Модуль 5. Слой silver и воспроизводимые трансформации
**Статус:** `Draft` **Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-07` **Последнее обновление:** `2026-09-15`
## Цель ## Цель
@@ -88,9 +88,9 @@ LEFT JOIN (не INNER), чтобы не терять строки с LocationID
- **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT) - **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT)
- **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance) - **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance)
### Taxi zone lookup: жёсткий prerequisite ### Taxi zone lookup: prerequisite из демо-секции Модуля 4
Lookup — самостоятельное задание Модуля 4. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни самостоятельное задание (Секция 8).» Lookup создаётся в демонстрационном коде Модуля 4 (Секция 8), а не в самостоятельном задании. Это гарантирует, что студент, прогнавший готовые ячейки Модуля 4, имеет обе bronze-таблицы. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни Секцию 8.»
### Cleanup: нет ### Cleanup: нет
@@ -118,8 +118,8 @@ Silver нужен Модулям 6 и 8. Spark-сессия останавлив
- **[md]** Ключевая идея: silver = bronze + явные правила трансформации + проверки качества. - **[md]** Ключевая идея: silver = bronze + явные правила трансформации + проверки качества.
### Секция 1: Spark-сессия и проверка bronze ### Секция 1: Spark-сессия и проверка bronze
- **[code]** SparkSession (паттерн Модулей 2-4: без `.master()`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4). - **[code]** SparkSession (паттерн Модулей 2-4: адрес кластера из `spark-defaults.conf`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4).
- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к самостоятельному заданию Модуля 4 (Секция 8). - **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к Секции 8 Модуля 4.
- **[md]** Обе bronze-таблицы на месте. Теперь строим silver. - **[md]** Обе bronze-таблицы на месте. Теперь строим silver.
### Секция 2: Профиль bronze — что нужно трансформировать ### Секция 2: Профиль bronze — что нужно трансформировать
@@ -148,7 +148,7 @@ Silver нужен Модулям 6 и 8. Spark-сессия останавлив
### Секция 7: Создание silver-таблицы ### Секция 7: Создание silver-таблицы
- **[md]** Используем CTAS с `CREATE OR REPLACE`. Namespace `lakehouse.silver`. - **[md]** Используем CTAS с `CREATE OR REPLACE`. Namespace `lakehouse.silver`.
- **[md]** Явное предупреждение о `CREATE OR REPLACE`: это полная перезапись таблицы с потерей предыдущего состояния и snapshot-истории. Для учебного silver это осознанный компромисс ради идемпотентности (повторный запуск ноутбука пересоздаёт таблицу с чистого листа). В рабочих сценариях это разрушительная операция — в Модуле 7 студент увидит, почему нужен другой подход. Параллель: `DROP TABLE + CREATE TABLE AS SELECT` в PostgreSQL. - **[md]** Явное предупреждение о `CREATE OR REPLACE`: операция атомарно заменяет текущее содержимое, схему и partition spec, но сохраняет прежние snapshot-ы для time travel. Новый snapshot не имеет `parent_id`, поэтому старые snapshot-ы перестают быть предками текущего состояния. Для учебного silver это осознанный компромисс ради воспроизводимого результата без дублирования строк. В Модуле 7 студент подробно сравнит замену и rollback.
- **[code]** `CREATE NAMESPACE IF NOT EXISTS lakehouse.silver`. Регистрация temp view. CTAS с явным списком демо-схемы: 24 колонки (19 оригинальных + 5 новых). Это каноническая схема, на которую опираются Модули 6 и 8. - **[code]** `CREATE NAMESPACE IF NOT EXISTS lakehouse.silver`. Регистрация temp view. CTAS с явным списком демо-схемы: 24 колонки (19 оригинальных + 5 новых). Это каноническая схема, на которую опираются Модули 6 и 8.
- **[md]** Предупреждение о времени выполнения (2-4 мин на 3 мес данных). - **[md]** Предупреждение о времени выполнения (2-4 мин на 3 мес данных).
- **[code]** Верификация: count, printSchema. - **[code]** Верификация: count, printSchema.
@@ -260,7 +260,7 @@ speed_mph DOUBLE (E4: новая, trip_distance / (tr
- `notebooks/05_silver_layer.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 4 (bronze-таблицы в каталоге). - `notebooks/05_silver_layer.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 4 (bronze-таблицы в каталоге).
- Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. - Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
- Ноутбук использует паттерны из Модулей 1-4: SparkSession без `.master()`, `setLogLevel("ERROR")`. - Ноутбук использует паттерны из Модулей 1-4: SparkSession с адресом кластера из `spark-defaults.conf`, `setLogLevel("ERROR")`.
- Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки. - Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки.
- CTAS использует явный список колонок, а не `SELECT *`. - CTAS использует явный список колонок, а не `SELECT *`.
- Правила трансформации зафиксированы в markdown-таблице перед кодом, а не только в коде. - Правила трансформации зафиксированы в markdown-таблице перед кодом, а не только в коде.
@@ -272,11 +272,11 @@ speed_mph DOUBLE (E4: новая, trip_distance / (tr
## Риски ## Риски
- **Taxi zone lookup отсутствует.** Самостоятельное задание Модуля 4 может быть не выполнено. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями. - **Taxi zone lookup отсутствует.** Lookup создаётся в демо-секции Модуля 4 (Секция 8), поэтому риск минимален — студент должен был прогнать готовые ячейки. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями.
- **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown. - **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown.
- **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение. - **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение.
- **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила). - **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила).
- **Идемпотентность.** `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — тот же паттерн, что в Модуле 4. Пересоздаёт таблицу целиком. - **Повторный запуск.** `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) — тот же паттерн, что в Модуле 4. Атомарно заменяет текущее содержимое, не дублирует строки и добавляет новый snapshot, сохраняя прежние для time travel.
## Out of Scope ## Out of Scope
@@ -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 с адресом кластера из `spark-defaults.conf`, `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.
+460
View File
@@ -0,0 +1,460 @@
# Модуль 7. Безопасная работа с таблицами: schema evolution и time travel
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-09-15`
## Цель
Научить студента безопасно изменять 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-предок и не создаёт новый snapshot;
- добавляет запись в `.history` о смене текущего состояния, а прежние snapshot-ы остаются доступными;
- старые data files не удаляются — они остаются до процедуры `expire_snapshots` / vacuum (Модуль 8);
- отдалённо напоминает перемещение указателя ветки в git, но snapshot-ы Iceberg остаются доступными до явного `expire_snapshots`.
Точный синтаксис 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]** Удаление одноразовой демо-таблицы через `DROP TABLE IF EXISTS ... PURGE`, затем создание `lakehouse.default.taxi_changes_demo` через CTAS (1000 строк из silver). Явное удаление гарантирует один начальный snapshot при повторном запуске:
```python
spark.sql("DROP TABLE IF EXISTS lakehouse.default.taxi_changes_demo PURGE")
spark.sql("""
CREATE 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 (`append` от 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]** `.snapshots`: по-прежнему 3 snapshot-а. `.history`: появилась четвёртая запись, снова указывающая на snapshot 2; ошибочный snapshot помечен `is_current_ancestor = false`.
- **[md]** Rollback не удаляет историю и файлы, но и не создаёт новый snapshot. Он переключает текущее состояние на существующий snapshot-предок. Сравнение:
| Свойство | `rollback_to_snapshot` | `CREATE OR REPLACE TABLE ... AS SELECT` |
|---|---|---|
| Создаёт новый snapshot | Нет | Да |
| Что становится текущим | Существующий snapshot-предок | Новый snapshot с результатом `SELECT` |
| Что происходит со старыми snapshot-ами | Сохраняются | Сохраняются для time travel |
| Цепочка предков | Возвращаемся к предку | Новый snapshot не имеет `parent_id`; прежние snapshot-ы перестают быть предками текущего |
- **[md]** `CREATE OR REPLACE` не создаёт отдельную именованную ветку: текущей остаётся `main`. Старые data files сохраняются до процедуры `expire_snapshots` — это тема Модуля 8.
### Секция 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. В `SparkCatalog` операция атомарно создаёт новый snapshot и сохраняет прежние snapshot-ы для time travel. Но она заменяет текущее содержимое, схему и partition spec. Новый snapshot не имеет `parent_id`, поэтому прежние snapshot-ы перестают быть предками текущего состояния. Если нужно перезаписать данные, сохранив текущую схему и партиционирование, следует использовать `INSERT OVERWRITE`.
**Ошибка 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-история содержит учебные эксперименты. Поскольку `CREATE OR REPLACE` сохраняет snapshot-ы, одноразовую таблицу удаляем через `PURGE` и создаём заново.
- **[code]** Сброс и новое создание демо-таблицы (тот же CTAS, что в Секции 6):
```python
spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE")
spark.sql("""
CREATE 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 (ответ).** Сравни `rollback_to_snapshot` и `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF <snapshot_id>`: какая операция создаёт новый snapshot, что становится текущим и как меняется цепочка `parent_id`?
- **[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 PURGE")`.
- **[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 с адресом кластера из `spark-defaults.conf`, `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` синтаксис.** Именованные параметры (`table => '...', snapshot_id => ...`) проверены на Spark 3.5.1 и Iceberg 1.10.0. `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF <snapshot_id>` не является эквивалентом rollback: он переписывает данные в новый snapshot без родителя и может заменить схему и partition spec.
- **`ALTER TABLE ADD COLUMNS` vs `ADD COLUMN`.** В Spark SQL с Iceberg синтаксис — `ADD COLUMNS` (множественное число). Проверить на стенде.
- **Количество snapshot-ов silver.** После первого запуска Модуля 5 у silver будет 1 snapshot; каждый повторный `CREATE OR REPLACE` добавляет новый, сохраняя старые для time travel. `ADD COLUMN` не создаёт data snapshot, а демо-таблица наращивает snapshot-историю через INSERT.
- **Time travel через Trino.** Синтаксис `FOR VERSION AS OF <snapshot_id>` проверен на Trino 478 с общим JDBC-каталогом и MinIO.
- **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). Одноразовая демо-таблица перед каждой исходной CTAS удаляется через `DROP TABLE ... PURGE`, чтобы проверки числа snapshot-ов были воспроизводимы.
- **Сброс демо-таблицы.** Два последовательных цикла `DROP TABLE ... PURGE` + `CREATE TABLE ... AS SELECT` проверены на стенде: каждый цикл начинает историю ровно с одного snapshot-а.
- **Влияние на Модуль 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,522 @@
# Модуль 8. Базовое обслуживание таблиц и финальная практика
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-09-15`
## Цель
Научить студента базовому обслуживанию 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]** Удаление одноразовой демо-таблицы через `DROP TABLE IF EXISTS ... PURGE`, затем создание с 500 строками (CTAS из silver). Это гарантирует один начальный snapshot и воспроизводимое число snapshot-ов при повторном запуске:
```python
spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE")
spark.sql(f"""
CREATE 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} PURGE")`. Вывод подтверждения.
### Секция 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 TABLE ... PURGE` для одноразовой демо-таблицы `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 с адресом кластера из `spark-defaults.conf`, `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-ов, а не добиться значимого улучшения производительности.
- **Повторный запуск.** Перед созданием одноразовой демо-таблицы выполняется `DROP TABLE ... PURGE`, иначе `CREATE OR REPLACE` сохранил бы прежние snapshot-ы и сломал ожидаемое число 9. В финальной практике студент должен учитывать уже существующие namespace и таблицы после прерванного запуска; cleanup в конце выполняется через `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.
+107
View File
@@ -0,0 +1,107 @@
# Проверка лимита памяти Trino
Дата: 19 октября 2026 года. Это отчет о проверке; актуальная конфигурация
находится в `docker-compose.yml` и `trino/jvm.config`, требования для учащегося —
в `START_HERE.md`.
## Что проверяли
Trino 478 в контейнере с пределом 2 ГиБ RAM, без swap, с Java heap от 512 МиБ
до 1 ГиБ. JVM сама подбирает размер регионов G1; вместо настройки образа
`G1HeapRegionSize=32M` для большого heap получен регион 1 МиБ.
Среда: Linux x86_64, Docker Engine, cgroup v2, 6 CPU и 11,14 ГиБ памяти,
видимых Docker. Остальные сервисы стенда работали одновременно. Общего лимита
8 ГБ для проекта или виртуальной машины в этом прогоне не было.
Исходные файлы основного маршрута курса:
| Файл | Размер, байт |
| --- | ---: |
| `yellow_tripdata_2024-01.parquet` | 49 961 641 |
| `yellow_tripdata_2024-02.parquet` | 50 349 284 |
| `yellow_tripdata_2024-03.parquet` | 60 078 280 |
| `taxi_zone_lookup.csv` | 12 331 |
Файлы взяты из уже скачанного учебного набора, без выборки или сокращения.
## Сценарий
1. Перед изменением проверены отсутствие активных Spark-приложений и запросов
Trino, чтение существующей таблицы `lakehouse.default.spark_trino_smoke`.
2. Пересоздан только Trino. Запуск до `SERVER STARTED` занял около 25 секунд,
healthcheck прошел.
3. Созданы отдельная временная схема `trino_memory_<id>` и собственный префикс
`validation/trino_memory_<id>/raw/` в MinIO. Таблицы курса не перезаписывались.
4. Spark подготовил bronze и справочник по SQL Модуля 4; фильтры, нормализации,
два JOIN со справочником и silver выполнены кодом Модуля 5. Проверки качества
этого модуля также прошли. Получены 9 554 778 строк bronze, 9 418 184 строк
silver и 265 строк справочника.
5. Через Python-клиент Trino выполнены 11 запросов из Модуля 6: просмотр схем,
таблиц и колонок, выборки, подсчет строк, общие метрики, агрегаты по районам
и топ-10 зон. Имена таблиц заменены на временные; SQL сохранен.
6. Дополнительно дважды выполнен JOIN всей bronze со справочником зон и
группировкой по району:
```sql
SELECT z.Borough, count(*) AS trips, avg(b.fare_amount) AS avg_fare
FROM <bronze> b
LEFT JOIN <zones> z ON b.PULocationID = z.LocationID
GROUP BY z.Borough
ORDER BY trips DESC;
```
7. Подсчет строк, общие метрики, агрегаты по районам, топ зон, чтение созданной
Spark таблицы итогов и оба JOIN сверены с результатами Spark. Для чисел с
плавающей точкой использован допуск `1e-8`. Ошибки Python-клиента Trino
пробрасывались наружу, без обработчика исключений из ноутбука.
8. Повторно прочитана прежняя smoke-таблица: результат `1 / from_spark` сохранен.
Всего завершились успешно 14 запросов. Временные таблицы удалены с `PURGE`,
затем удалены временная схема и собственный raw-префикс. После `PURGE`
отдельно удалены восемь пустых маркеров каталогов в собственном warehouse-префиксе.
Проверено отсутствие временных объектов; исходные Parquet/CSV на хосте сохранены.
## Измерения
| Показатель | Результат |
| --- | ---: |
| Лимит контейнера (`memory.max`) | 2 147 483 648 байт |
| Лимит swap (`memory.swap.max`) | 0 |
| Фактический `MaxHeapSize` через `jcmd 1 VM.flags` | 1 073 741 824 байта |
| Пик контейнера Trino (`memory.peak`) | 1 340 874 752 байта, около 1,25 ГиБ |
| События `oom`, `oom_kill`, `max` в `memory.events` | 0 |
| Перезапуски Trino | 0 |
| Пик суммы `memory.current` восьми контейнеров | около 5,89 ГиБ |
| Пик той же суммы за вычетом `inactive_file` | около 5,44 ГиБ |
Суммарная память снималась раз в секунду, получено 165 отсчетов во время
подготовки таблиц, запросов и очистки. Пик Trino взят из счетчика cgroup,
который учитывает и запуск контейнера. Вычитание `inactive_file` приближает
сумму к показателю памяти в `docker stats`; файловый кэш показан отдельно.
Агрегаты по silver обработали 9 418 184 строки каждый, что подтверждено
статистикой запросов Trino. `count(*)` по bronze использовал метаданные Iceberg;
проверкой чтения bronze служил также JOIN со справочником.
## Границы вывода
Бюджет Trino 2 ГиБ / heap 1 ГиБ достаточен для проверенных запросов на основном
наборе курса при одном учащемся. Измерение не подтверждает работу всех восьми
модулей под общим жестким лимитом 8 ГБ. В сумму контейнеров не входят Docker
daemon, ядро и другие процессы Linux/WSL2. Docker Desktop, Windows, ARM64,
параллельные занятия и расширенный набор Taxi за 12 месяцев не проверялись.
Для повторной проверки подними стенд с этой конфигурацией и пройди модули 3–6
на трех месяцах Taxi, добавив JOIN выше. Временная схема нужна, если важно
сохранить существующие учебные таблицы. Примененный лимит можно проверить
с хоста:
```bash
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemorySwap}}' trino
docker compose exec trino jcmd 1 VM.flags
docker compose exec trino cat /sys/fs/cgroup/memory.peak
docker compose exec trino cat /sys/fs/cgroup/memory.events
```
Последние два пути относятся к cgroup v2; счетчики накапливаются с запуска
контейнера. Числа нового прогона могут отличаться.
+3
View File
@@ -1,5 +1,8 @@
# spark/spark-defaults.conf # spark/spark-defaults.conf
# Адрес Spark-кластера для новых сессий
spark.master spark://spark-master:7077
# Включаем Iceberg extensions # Включаем Iceberg extensions
spark.sql.extensions org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions spark.sql.extensions org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
+6 -7
View File
@@ -2,30 +2,29 @@ from __future__ import annotations
from pyspark.sql import SparkSession from pyspark.sql import SparkSession
DEFAULT_MASTER_URL = "spark://spark-master:7077" EXPECTED_MASTER_URL = "spark://spark-master:7077"
DEFAULT_APP_NAME = "cluster-smoke" DEFAULT_APP_NAME = "cluster-smoke"
DEFAULT_ROW_COUNT = 1_000_000 DEFAULT_ROW_COUNT = 1_000_000
def create_spark_session( def create_spark_session(
app_name: str = DEFAULT_APP_NAME, app_name: str = DEFAULT_APP_NAME,
master_url: str = DEFAULT_MASTER_URL,
) -> SparkSession: ) -> SparkSession:
"""Создать SparkSession для проверок окружения в Модуле 1. """Создать SparkSession для проверок окружения в Модуле 1.
Функция специально остаётся минимальной и опирается на смонтированный Адрес кластера и настройки Iceberg и MinIO заданы в spark-defaults.conf.
в контейнер `spark-defaults.conf` для остальной Spark/Iceberg-конфигурации.
""" """
return SparkSession.builder.appName(app_name).master(master_url).getOrCreate() return SparkSession.builder.appName(app_name).getOrCreate()
def run_cluster_smoke(spark: SparkSession, row_count: int = DEFAULT_ROW_COUNT) -> dict: def run_cluster_smoke(spark: SparkSession, row_count: int = DEFAULT_ROW_COUNT) -> dict:
spark_context = spark.sparkContext spark_context = spark.sparkContext
master_url = spark_context.master master_url = spark_context.master
if master_url.startswith("local"): if master_url != EXPECTED_MASTER_URL:
raise RuntimeError( raise RuntimeError(
"Smoke test must run against the Spark cluster, not local mode." "Smoke test must run against "
f"{EXPECTED_MASTER_URL}, got {master_url}."
) )
# Берём больше партиций, чем даёт defaultParallelism, чтобы задача точно # Берём больше партиций, чем даёт defaultParallelism, чтобы задача точно
+100
View File
@@ -0,0 +1,100 @@
// Запуск через browser_ide.sh: настоящий notebook editor, локальное ядро и webview.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const notebookName = 'lakehouse-browser-smoke.ipynb';
const notebook = JSON.parse(fs.readFileSync(
path.join(__dirname, '../notebooks/01_environment_and_smoke_test.ipynb'), 'utf8',
));
const smokeCell = notebook.cells.findIndex(cell =>
cell.source.join('').includes('smoke_report = run_cluster_smoke(spark)'),
);
async function openNotebook(page) {
await page.keyboard.press('Control+p');
await page.keyboard.type(`/tmp/${notebookName}`);
await page.getByRole('option').filter({ hasText: notebookName }).first().waitFor();
await page.keyboard.press('Enter');
await page.getByRole('list', { name: /Notebook Use/ }).waitFor();
}
async function checkOutput(page) {
// Notebook editor виртуализирует ячейки: сначала показываем нужную ячейку.
const list = page.getByRole('list', { name: /Notebook Use/ });
await list.press('Control+Home');
for (let i = 0; i < smokeCell; i++) await list.press('ArrowDown');
await page.getByRole('button', {
name: `Cell ${smokeCell + 1} of ${notebook.cells.length}`, exact: true,
}).waitFor();
for (let attempt = 0; attempt < 30; attempt++) {
for (const frame of page.frames()) {
const output = frame.getByText('result_sum=499999500000', { exact: false });
if (await output.count() && await output.first().isVisible()) return;
}
await page.waitForTimeout(500);
}
throw new Error('Notebook webview did not render the expected Spark result');
}
(async () => {
assert(smokeCell >= 0, 'Module 1 must contain the cluster smoke cell');
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(30000);
const webviewErrors = [];
page.on('console', message => {
if (/Error loading webview|Could not register.*service.?worker/i.test(message.text())) {
webviewErrors.push(message.text());
}
});
try {
await page.goto(process.env.IDE_URL || 'http://localhost:8889', {
waitUntil: 'domcontentloaded',
});
assert(await page.evaluate(() => isSecureContext && 'serviceWorker' in navigator));
await page.getByRole('tree', { name: 'Files Explorer' }).waitFor();
// Баннер появляется позже Explorer; при повторном тесте доверие уже сохранено.
const restricted = page.getByRole('banner').filter({ hasText: 'Restricted Mode' });
await restricted.waitFor({ timeout: 10000 }).catch(error => {
if (error.name !== 'TimeoutError') throw error;
});
if (await restricted.count()) {
await restricted.getByRole('button', { name: 'Manage', exact: true }).click();
await page.getByRole('button', { name: 'Trust', exact: true }).click();
await page.getByRole('button', { name: 'Close Modal Editor (Escape)' }).click();
await restricted.waitFor({ state: 'hidden' });
}
console.log('PASS: workspace trust enabled');
await openNotebook(page);
await page.getByRole('button', { name: 'Select Kernel', exact: true }).click();
await page.getByRole('option', { name: 'Python Environments...' }).click();
await page.getByRole('option').filter({ hasText: '/usr/bin/python3' }).click();
console.log('PASS: notebook editor and local Python kernel selection');
await page.getByRole('button', { name: 'Run All', exact: true }).click();
await page.getByRole('button', { name: 'Interrupt', exact: true }).waitFor();
await page.getByRole('button', { name: 'Run All', exact: true }).waitFor({ timeout: 120000 });
await checkOutput(page);
await page.keyboard.press('Control+s');
console.log('PASS: Spark output rendered in notebook webview over HTTP localhost');
await page.getByRole('tab', { name: notebookName, exact: true })
.getByRole('button', { name: 'Close (Ctrl+W)' }).click();
await openNotebook(page);
await checkOutput(page);
assert.deepEqual(webviewErrors, []);
await page.screenshot({ path: '/artifacts/notebook.png' });
await page.getByRole('tab', { name: notebookName, exact: true })
.getByRole('button', { name: 'Close (Ctrl+W)' }).click();
console.log('PASS: saved notebook reopened with its output');
} catch (error) {
await page.screenshot({ path: '/artifacts/failure.png' }).catch(() => {});
throw error;
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Проверка UI на Linux Docker Engine; зависимости браузера остаются в контейнере.
set -euo pipefail
cd "$(dirname "$0")/.."
artifact_dir=$(mktemp -d /tmp/lakehouse-browser-check.XXXXXX)
echo "Browser artifacts: $artifact_dir"
docker compose exec -T ide cp /opt/work/01_environment_and_smoke_test.ipynb \
/tmp/lakehouse-browser-smoke.ipynb
docker run --rm --network host --shm-size=1g \
-e IDE_URL="${IDE_URL:-http://localhost:8889}" \
-v "$PWD:/repo:ro" -v "$artifact_dir:/artifacts" \
mcr.microsoft.com/playwright:v1.56.1-noble \
bash -c 'npm install --prefix /tmp/browser-test --no-audit --no-fund playwright@1.56.1 >/dev/null &&
NODE_PATH=/tmp/browser-test/node_modules node /repo/tests/browser_ide.cjs'
docker compose exec -T ide python3 - <<'PY'
import json
from pathlib import Path
path = Path('/tmp/lakehouse-browser-smoke.ipynb')
notebook = json.loads(path.read_text())
cells = [cell for cell in notebook['cells'] if cell['cell_type'] == 'code']
assert all(cell['execution_count'] is not None for cell in cells)
assert not any(output['output_type'] == 'error'
for cell in cells for output in cell.get('outputs', []))
output = '\n'.join(''.join(item.get('text', []))
for cell in cells for item in cell.get('outputs', []))
assert 'master_url=spark://spark-master:7077' in output
assert 'result_sum=499999500000' in output
path.unlink()
print(f'PASS: all {len(cells)} code cells executed and saved without errors')
PY
+17
View File
@@ -0,0 +1,17 @@
# Параметры образа trinodb/trino:478 с бюджетом памяти для локального курса.
-server
-agentpath:/usr/lib/trino/bin/libjvmkill.so
-Xms512M
-Xmx1G
# Размер регионов G1 выбирает JVM с учетом небольшого heap.
-XX:+ExplicitGCInvokesConcurrent
-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:-OmitStackTraceInFastThrow
-XX:ReservedCodeCacheSize=256M
-XX:PerMethodRecompilationCutoff=10000
-XX:PerBytecodeRecompilationCutoff=10000
-Djdk.attach.allowAttachSelf=true
-Djdk.nio.maxCachedBufferSize=2000000
-Dfile.encoding=UTF-8
-XX:+EnableDynamicAgentLoading