docs(plan): добавлен план модуля 8 по обслуживанию таблиц

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