Set an explicit expiration cutoff because retain_last alone preserves fresh snapshots under the default five-day age threshold. Add result checks, distinguish time travel from rollback, and explain schema metadata versions. Validated changed cells with Spark 3.5.1 and Iceberg 1.10.0 in an isolated container: 10 to 2 snapshots, 1300 rows preserved, expired time travel rejected, repeat execution in UTC and America/New_York. Notebook validation, Python syntax, local links and diff checks pass.
817 lines
38 KiB
Plaintext
817 lines
38 KiB
Plaintext
{
|
||
"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
|
||
}
|