Compare commits
31
Commits
82d067d6c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bdce356e3 | ||
|
|
db8f275ea0 | ||
|
|
863d658a5f | ||
|
|
21f3570b89 | ||
|
|
78cf7b5fae | ||
|
|
7d451a7ce9 | ||
|
|
bf3241124c | ||
|
|
872b876be1 | ||
|
|
cc0cd3857d | ||
|
|
9d5b0b716e | ||
|
|
16f76d63fc | ||
|
|
7c5e976405 | ||
|
|
00942c255f | ||
|
|
2cdecd82f8 | ||
|
|
a5244f8a0b | ||
|
|
91a5c85469 | ||
|
|
a085b2fc55 | ||
|
|
ff474f05f6 | ||
|
|
34a3cb309c | ||
|
|
2e961fd0c7 | ||
|
|
1bf8a16111 | ||
|
|
df39ff9e64 | ||
|
|
0ae83cecdb | ||
|
|
4bea2af9d2 | ||
|
|
9ffb9b80e0 | ||
|
|
65d57ec6d6 | ||
|
|
68c530c855 | ||
|
|
bcf7975227 | ||
|
|
4a736f6c4e | ||
|
|
a31a020a76 | ||
|
|
39b1a5618c |
No files matched your search
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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, явно укажи на
|
||||||
|
конфликт, а не отменяй ранее принятое решение молча.
|
||||||
@@ -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 карты ссылку на сохранённый контекст
|
||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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 (финальная практика).
|
||||||
@@ -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`, если меняется верхнеуровневый вход в репозиторий.
|
||||||
|
|
||||||
|
|||||||
@@ -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 нельзя пропускать** даже для опытных студентов. Они содержат ключевые практики, которые отличают «знаю теорию» от «умею делать руками».
|
||||||
@@ -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
@@ -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` — заметки для ведения курса с ментором (опционально).
|
||||||
@@ -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` на проблемный сервис, если ошибка в другом компоненте.
|
||||||
|
Для ошибки ноутбука добавь имя выбранного ядра и вывод ячейки. Пароли, токены
|
||||||
|
и адреса прокси с учетными данными в сообщение не включай.
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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#маршрут-по-модулям)."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 |
|
||||||
@@ -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.
|
||||||
@@ -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. Все исполняемые ячейки, их порядок и контрольное решение
|
||||||
|
совпадают с проверенной версией. Стенд повторно не запускался.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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-слой.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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; счетчики накапливаются с запуска
|
||||||
|
контейнера. Числа нового прогона могут отличаться.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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, чтобы задача точно
|
||||||
|
|||||||
@@ -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; });
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user