fix(course): исправлена семантика Iceberg snapshots

- Зачем: объяснения CREATE OR REPLACE и rollback расходились с поведением Iceberg 1.10.0 и ломали повторный запуск упражнений.
- Что: исправлены материалы и планы модулей 2, 4, 5, 7 и 8; одноразовые таблицы сбрасываются через DROP TABLE PURGE.
- Проверка: JSON и Python AST валидны; на Spark 3.5.1, Iceberg 1.10.0, JDBC/MinIO и Trino 478 подтверждены rollback, replace, time travel и повторный сброс до одного snapshot-а.
This commit is contained in:
ddadmin committed 2026-09-15 07:35:39 -04:00
1 parent ff474f05f6
commit a085b2fc55
11 files changed
+106 -76

No files matched your search

+1 -1
View File
@@ -72,7 +72,7 @@
- **Студент путает schema evolution и data snapshot.** `ALTER TABLE ADD COLUMNS` не создаёт новый data snapshot — это metadata-only операция. Snapshot создаётся только при изменении данных (INSERT, DELETE и т.д.).
- **Time travel: забывает сохранить snapshot_id.** Без сохранённого ID в переменную приходится заново запрашивать `table.snapshots`. Привычка: перед экспериментом запиши ID текущего состояния.
- **`rollback_to_snapshot` vs `CREATE OR REPLACE`.** Ключевое отличие: rollback сохраняет историю, `CREATE OR REPLACE` уничтожает её. Это описано в Секции 10 ноутбука.
- **`rollback_to_snapshot` vs `CREATE OR REPLACE`.** Rollback переключает текущее состояние на существующий snapshot-предок и не создаёт новый snapshot. `CREATE OR REPLACE` создаёт новый snapshot без родителя и заменяет текущие данные, схему и партиционирование; прежние snapshot-ы сохраняются для time travel, но перестают быть предками текущего состояния. Это описано в Секциях 8 и 10 ноутбука.
### Модуль 8. Обслуживание и финальная практика
+4 -4
View File
@@ -264,9 +264,9 @@
"metadata": {},
"outputs": [],
"source": [
"# Удаляем таблицы (это удалит их и из каталога, и из хранилища)\n",
"spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.demo_table\")\n",
"spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.my_first_table\")\n",
"# PURGE удаляет одноразовые таблицы и из каталога, и из хранилища\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 PURGE\")\n",
"\n",
"# Удаляем namespace\n",
"spark.sql(\"DROP NAMESPACE IF EXISTS lakehouse.module_02\")\n",
@@ -297,4 +297,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}
+2 -2
View File
@@ -216,7 +216,7 @@
"cell_type": "markdown",
"id": "350d2087",
"metadata": {},
"source": "## 3. Создание `bronze` namespace и `Iceberg`-таблицы\n\nСначала создаём namespace `lakehouse.bronze`, а затем выполняем `CTAS` (`CREATE OR REPLACE TABLE ... USING iceberg AS SELECT ...`).\n\nЧто такое namespace в `Iceberg`:\n\n- это логический контейнер для таблиц;\n- если ты работал с `PostgreSQL` — это ближайший аналог `schema` внутри базы;\n- запись `lakehouse.bronze.nyc_taxi_yellow` читается как `catalog.namespace.table`.\n\nПочему здесь используем именно `bronze`, а не `module_04`:\n\n- имя сразу отражает роль слоя в пайплайне `raw -> bronze -> silver`;\n- Модуль 5 будет читать именно `lakehouse.bronze.*`, и семантическое имя делает маршрут прозрачным;\n- в отличие от демонстрационного `module_02`, этот слой не временный и будет использоваться в следующих модулях.\n\nЭто удобный учебный компромисс:\n\n- один шаг создаёт таблицу и сразу наполняет её;\n- повторный запуск секции остаётся идемпотентным;\n- минус: `CREATE OR REPLACE` пересоздаёт таблицу и сбрасывает старую историю snapshot-ов.\n\nДля первого `bronze`-слоя это допустимо. В Модуле 7 мы отдельно разберём, почему в рабочих сценариях нужно аккуратнее относиться к истории таблицы."
"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",
@@ -650,4 +650,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}
+2 -2
View File
@@ -280,7 +280,7 @@
"\n",
"Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) в namespace `lakehouse.silver`.\n",
"\n",
"**Важное предупреждение:** `CREATE OR REPLACE` — это полная перезапись таблицы. Все предыдущие данные и история snapshot-ов будут потеряны. В учебном silver это осознанный компромисс ради идемпотентности: повторный запуск ноутбука пересоздаёт таблицу с чистого листа. В Модуле 7 мы увидим, как делать изменения безопаснее.\n",
"**Важное предупреждение:** `CREATE OR REPLACE` атомарно заменяет текущее содержимое таблицы и может заменить её схему и партиционирование. При этом Iceberg сохраняет предыдущие snapshot-ы для time travel, но новый snapshot не продолжает их цепочку: старые состояния перестают быть предками текущего. В учебном silver это осознанный компромисс ради воспроизводимого результата без дублирования строк. В Модуле 7 мы разберём эту семантику подробнее.\n",
"\n",
"На 3 месяцах данных (~7 млн строк) эта операция может занять 2-4 минуты."
]
@@ -610,4 +610,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}
+40 -22
View File
@@ -153,7 +153,7 @@
"- `operation` — тип операции (append, overwrite).\n",
"- `summary` — краткая статистика (added-data-files, total-records и т.д.).\n",
"\n",
"Если вы не перезапускали Модуль 5 через CREATE OR REPLACE, у silver будет 1 snapshot (от CTAS).\n",
"После первого запуска Модуля 5 у silver будет 1 snapshot от CTAS. Каждый повторный `CREATE OR REPLACE` добавит ещё один snapshot: прежние состояния сохранятся, хотя перестанут быть предками текущего.\n",
"В Greenplum такой истории нет: после `INSERT` предыдущее состояние таблицы недоступно без бэкапа.\n"
]
},
@@ -211,7 +211,7 @@
"Колонка добавлена. Данные не изменились — Iceberg не перезаписывал data files. \n",
"В рабочем сценарии `loaded_at` заполнялся бы при следующих INSERT-ах: `INSERT INTO ... SELECT ..., current_timestamp() AS loaded_at FROM ...`. Существующие строки остаются с NULL — это нормальная практика эволюции схемы. Заполнение существующих строк (UPDATE) — отдельная тема, требующая перезаписи файлов.\n",
"\n",
"Примечание: если вы повторно запустите Модуль 5 после Модуля 7, `CREATE OR REPLACE TABLE` пересоздаст silver без колонки `loaded_at`. Это ещё одна иллюстрация того, почему `CREATE OR REPLACE` опасен в рабочих сценариях — он стирает все изменения, включая schema evolution.\n"
"Примечание: если вы повторно запустите Модуль 5 после Модуля 7, `CREATE OR REPLACE TABLE` заменит текущую схему silver, поэтому колонка `loaded_at` исчезнет из текущего состояния. Предыдущие snapshot-ы при этом сохранятся для time travel, но перестанут быть предками текущего snapshot-а.\n"
]
},
{
@@ -288,9 +288,13 @@
"metadata": {},
"outputs": [],
"source": [
"# Демо-таблица одноразовая: удаляем её вместе с историей,\n",
"# чтобы при повторном запуске снова получить ровно один snapshot.\n",
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"\n",
"# Создание демо-таблицы\n",
"spark.sql(f\"\"\"\n",
" CREATE OR REPLACE TABLE {DEMO_TABLE}\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",
@@ -504,7 +508,7 @@
"outputs": [],
"source": [
"# Посмотрим на 3-й snapshot (нашу ошибку)\n",
"spark.sql(f\"SELECT snapshot_id, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n"
"spark.sql(f\"SELECT snapshot_id, parent_id, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n"
]
},
{
@@ -563,23 +567,32 @@
"metadata": {},
"outputs": [],
"source": [
"spark.sql(f\"SELECT snapshot_id, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n"
"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": [
"Теперь в истории 4 записи. Четвёртый snapshot — это результат rollback, но его данные (набор файлов) в точности соответствуют snapshot 2.\n",
"В `.snapshots` по-прежнему три записи: rollback **не создал четвёртый snapshot**. Он переключил текущее состояние таблицы на уже существующий `snapshot_2`.\n",
"\n",
"Rollback **не удаляет историю** и **не удаляет файлы**. Он создаёт новый snapshot, указывающий на данные предыдущего состояния. Аналогия:\n",
"В `.history` появилась ещё одна запись о переключении текущего состояния. Она снова указывает на `snapshot_2`; snapshot с «плохими» строками сохранился, но больше не является предком текущего состояния (`is_current_ancestor = false`).\n",
"\n",
"| | git | Iceberg |\n",
"| Свойство | `rollback_to_snapshot` | `CREATE OR REPLACE TABLE ... AS SELECT` |\n",
"|---|---|---|\n",
"| Откат с сохранением истории | `git revert` (новый коммит) | `rollback_to_snapshot` (новый snapshot) |\n",
"| Откат с удалением истории | `git reset --hard` | `CREATE OR REPLACE` (уничтожает snapshot-ы) |\n",
"| Создаёт новый snapshot | Нет | Да |\n",
"| Что становится текущим | Существующий snapshot-предок | Новый snapshot с результатом `SELECT` |\n",
"| Что происходит со старыми snapshot-ами | Сохраняются | Сохраняются для time travel |\n",
"| Цепочка предков | Возвращаемся к предку | Новый snapshot не имеет `parent_id`; прежние snapshot-ы перестают быть предками текущего |\n",
"\n",
"Старые data files (включая файлы с «плохими» строками) остаются в хранилище (MinIO) до процедуры `expire_snapshots` — это тема Модуля 8. Rollback не чистит storage, он только меняет указатель текущего snapshot-а.\n"
"При `CREATE OR REPLACE` отдельная именованная ветка автоматически не создаётся: текущей остаётся ветка `main`. Старые data files сохраняются до `expire_snapshots` — эту процедуру разберём в Модуле 8.\n"
]
},
{
@@ -649,7 +662,7 @@
"### Секция 10: Типичные ошибки новичка\n",
"\n",
"**Ошибка 1: Слепой `CREATE OR REPLACE`.**\n",
"В Модулях 4 и 5 мы использовали `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для создания bronze и silver. Это был осознанный компромисс: первичная загрузка, нет предыдущей истории, которую нужно сохранять. Но в рабочих сценариях `CREATE OR REPLACE` — **деструктивная операция**: она уничтожает ВСЮ snapshot-историю таблицы. Если у таблицы было 100 snapshot-ов, после `CREATE OR REPLACE` останется один. Time travel к предыдущим состояниям станет невозможен.\n",
"В Модулях 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",
@@ -664,7 +677,7 @@
"source": [
"### Секция 11: Самостоятельное задание\n",
"\n",
"Пересоздадим демо-таблицу с чистого листа. После демонстраций выше её схема изменена, а история содержит учебные эксперименты. Для самостоятельной работы нужна чистая таблица.\n"
"Подготовим демо-таблицу с чистого листа. После демонстраций выше её схема изменена, а история содержит учебные эксперименты. Поскольку `CREATE OR REPLACE` сохраняет старые snapshot-ы, для одноразовой учебной таблицы явно используем `DROP TABLE ... PURGE`, а затем новый `CREATE TABLE`. Так самостоятельная работа снова начнётся ровно с одного snapshot-а.\n"
]
},
{
@@ -673,9 +686,12 @@
"metadata": {},
"outputs": [],
"source": [
"# Пересоздаем демо-таблицу (это удалит всю ее snapshot-историю - Ошибка 1 в действии!)\n",
"# Эта таблица одноразовая: PURGE удаляет её данные и snapshot-историю.\n",
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"\n",
"# Создаём новую таблицу с одним начальным snapshot-ом.\n",
"spark.sql(f\"\"\"\n",
" CREATE OR REPLACE TABLE {DEMO_TABLE}\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",
@@ -683,7 +699,9 @@
" FROM {SILVER_TABLE}\n",
" LIMIT 1000\n",
"\"\"\")\n",
"print(f\"Демо-таблица пересоздана начисто: {spark.table(DEMO_TABLE).count()} строк, 1 snapshot.\")\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"
]
},
{
@@ -804,7 +822,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"**Задача 5 (ответ).** Ответь текстом в ячейке ниже: чем `rollback_to_snapshot` отличается от `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF <snapshot_id>`? Что происходит с историей snapshot-ов в каждом случае?\n"
"**Задача 5 (ответ).** Сравни `rollback_to_snapshot` и `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF <snapshot_id>`. Какая операция создаёт новый snapshot? Что происходит с текущим состоянием, старыми snapshot-ами и цепочкой `parent_id`?\n"
]
},
{
@@ -843,7 +861,7 @@
"3. Чем `ADD COLUMN` отличается от `RENAME COLUMN` по влиянию на downstream?\n",
"4. Как прочитать предыдущее состояние таблицы? (назови два способа)\n",
"5. Что делает `rollback_to_snapshot`? Теряется ли при этом snapshot-история?\n",
"6. Почему `CREATE OR REPLACE` опаснее, чем `INSERT INTO` с последующим rollback?\n",
"6. Какие риски остаются у `CREATE OR REPLACE`, хотя старые snapshot-ы сохраняются?\n",
"7. Какой аналог time travel в классических СУБД (PostgreSQL/Greenplum) и почему он сложнее в использовании?\n"
]
},
@@ -862,8 +880,8 @@
"metadata": {},
"outputs": [],
"source": [
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE}\")\n",
"print(\"Демо-таблица удалена.\")\n"
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"print(\"Демо-таблица и её учебные данные удалены.\")\n"
]
},
{
@@ -874,7 +892,7 @@
"- Schema evolution (`ADD COLUMN`) — безопасная metadata-only операция, которая мгновенно видна из всех движков через общий каталог.\n",
"- Snapshot-ы — автоматическая история изменений данных, встроенная в Iceberg.\n",
"- Time travel — чтение предыдущих состояний без бэкапов.\n",
"- Rollback — восстановление после ошибки с сохранением истории.\n",
"- Rollback — переключение текущего состояния на существующий snapshot-предок без создания нового snapshot-а.\n",
"\n",
"В финальном Модуле 8 мы изучим базовое обслуживание таблиц: compaction (решение проблемы множества мелких файлов) и `expire_snapshots` (очистка старых файлов данных, оставшихся после time travel и rollback, которые мы научились применять в этом модуле).\n"
]
@@ -901,4 +919,4 @@
},
"nbformat": 4,
"nbformat_minor": 4
}
}
+10 -6
View File
@@ -135,7 +135,7 @@
"\n",
"Каждый `INSERT INTO` в Iceberg создаёт новые data files. Если вставлять данные мелкими порциями (частые микробатчи, ручные INSERT-ы, инкрементальные загрузки), таблица накапливает множество мелких файлов. Это замедляет чтение: движку приходится открывать и читать каждый файл отдельно. \n",
"\n",
"Создадим демо-таблицу и сымитируем эту проблему: один изначальный `CTAS` (500 строк) и 8 маленьких `INSERT INTO` (по 100 строк).\n",
"Создадим демо-таблицу и сымитируем эту проблему: один изначальный `CTAS` (500 строк) и 8 маленьких `INSERT INTO` (по 100 строк). Перед созданием удалим одноразовую таблицу через `DROP TABLE ... PURGE`, чтобы при повторном запуске история снова начиналась с одного snapshot-а.\n",
"*(Внимание: 8 INSERT-ов могут занять 1-2 минуты)*\n"
]
},
@@ -145,9 +145,13 @@
"metadata": {},
"outputs": [],
"source": [
"# Создание демо-таблицы (создаст 1 файл)\n",
"# Демо-таблица одноразовая: удаляем данные и старую 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 OR REPLACE TABLE {DEMO_TABLE}\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",
@@ -529,8 +533,8 @@
"metadata": {},
"outputs": [],
"source": [
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE}\")\n",
"print(\"Демо-таблица удалена.\")\n",
"spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE\")\n",
"print(\"Демо-таблица и её учебные данные удалены.\")\n",
"\n"
]
},
@@ -784,4 +788,4 @@
},
"nbformat": 4,
"nbformat_minor": 4
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
# Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-07`
**Последнее обновление:** `2026-09-15`
## Цель
@@ -33,7 +33,7 @@
- запрос к PostgreSQL (каталогу метаданных), чтобы увидеть регистрацию таблицы;
- запрос к MinIO (например, s3a list или boto3), чтобы увидеть появление файлов данных (parquet) и метаданных (json/avro).
5. Сформулировать самостоятельное задание: студент создает свою таблицу, пишет в нее данные и находит её следы в хранилище и каталоге.
6. Включить в конец ноутбука обязательный шаг очистки (cleanup) — удаление таблиц и namespace `module_02`, чтобы не оставлять мусор для следующих модулей.
6. Включить в конец ноутбука обязательный шаг очистки (cleanup) — удаление одноразовых таблиц через `DROP TABLE ... PURGE` и namespace `module_02`, чтобы не оставлять данные и метаданные для следующих модулей.
7. Описать вопросы для Checkpoint.
## Checkpoint
+7 -6
View File
@@ -1,7 +1,7 @@
# Модуль 4. Первая рабочая Iceberg-таблица и слой bronze
**Статус:** `Draft`
**Последнее обновление:** `2026-03-07`
**Последнее обновление:** `2026-09-15`
## Цель
@@ -50,11 +50,12 @@
### Создание и наполнение таблицы: 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;
- явный список колонок в 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, ...)`.
@@ -105,7 +106,7 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
### Секция 3: Загрузка raw → bronze (CTAS)
- **[md]** Аналогия с `CREATE TABLE ods AS SELECT ... FROM stg` в Greenplum. Почему явный список колонок: `SELECT *` привязывается к порядку, а не к именам — это хрупко и может дать некорректное сопоставление полей. Используем `CREATE OR REPLACE TABLE ... AS SELECT col1, col2, ... FROM raw_yellow` — CTAS одновременно создаёт таблицу и наполняет данными.
- **[md]** Идемпотентность: `CREATE OR REPLACE` при повторном запуске пересоздаёт таблицу с чистого листа — snapshot history остаётся чистой (один snapshot = одна загрузка). Trade-off: в production `CREATE OR REPLACE` — разрушительная операция (удаляет историю), но для первичной загрузки учебного bronze это осознанный выбор ради простоты. В Модуле 7 увидим, почему в рабочих сценариях нужен другой подход.
- **[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]** Верификация: count bronze vs count raw — должны совпасть.
@@ -216,7 +217,7 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
- **Схема raw PARQUET.** Фиксируем колонки NYC Taxi 2024 в DDL. Если студент скачал другой год — DDL может не совпасть. Пояснение: придерживаться инструкции из `START_HERE.md`.
- **Column case sensitivity.** NYC Taxi PARQUET использует mixed case (`VendorID`, `PULocationID`). Iceberg хранит имена case-sensitive. DDL должен совпадать с именами в PARQUET. Проверить на стенде при реализации.
- **Мелкие файлы.** Spark может создать много мелких data files (по числу partitions). Нормально для Module 4 — тема compaction отложена до Модуля 8. Упомянуть, не решать.
- **Идемпотентность.** Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — пересоздаёт таблицу целиком, snapshot history остаётся чистой. Это проще и надёжнее, чем `DELETE FROM` + `INSERT INTO` (который усложняет snapshot history и требует проверки поддержки `DELETE` на текущем стеке). Компромисс: `CREATE OR REPLACE` уничтожает предыдущие snapshots, но для учебного bronze это допустимо. В markdown явно объяснить этот trade-off.
- **Повторный запуск.** Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS): текущее содержимое заменяется атомарно и строки не дублируются. При каждом запуске появляется новый snapshot; предыдущие snapshot-ы сохраняются для time travel, но перестают быть предками текущего. В markdown явно объяснить этот trade-off и риск замены схемы и partition spec.
## Out of Scope
+3 -3
View File
@@ -1,7 +1,7 @@
# Модуль 5. Слой silver и воспроизводимые трансформации
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-07`
**Последнее обновление:** `2026-09-15`
## Цель
@@ -148,7 +148,7 @@ Silver нужен Модулям 6 и 8. Spark-сессия останавлив
### Секция 7: Создание 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.
- **[md]** Предупреждение о времени выполнения (2-4 мин на 3 мес данных).
- **[code]** Верификация: count, printSchema.
@@ -276,7 +276,7 @@ speed_mph DOUBLE (E4: новая, trip_distance / (tr
- **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown.
- **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение.
- **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила).
- **Идемпотентность.** `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — тот же паттерн, что в Модуле 4. Пересоздаёт таблицу целиком.
- **Повторный запуск.** `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) — тот же паттерн, что в Модуле 4. Атомарно заменяет текущее содержимое, не дублирует строки и добавляет новый snapshot, сохраняя прежние для time travel.
## Out of Scope
+28 -22
View File
@@ -1,7 +1,7 @@
# Модуль 7. Безопасная работа с таблицами: schema evolution и time travel
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-08`
**Последнее обновление:** `2026-09-15`
## Цель
@@ -69,9 +69,10 @@
Iceberg предоставляет stored procedure `CALL system.rollback_to_snapshot(table, snapshot_id)`. Это:
- создаёт НОВЫЙ snapshot, указывающий на данные старого — история не теряется;
- переключает текущее состояние на существующий snapshot-предок и не создаёт новый snapshot;
- добавляет запись в `.history` о смене текущего состояния, а прежние snapshot-ы остаются доступными;
- старые data files не удаляются — они остаются до процедуры `expire_snapshots` / vacuum (Модуль 8);
- семантически аналогично `git revert` (новый коммит, отменяющий изменение), а не `git reset --hard` (удаление истории).
- отдалённо напоминает перемещение указателя ветки в git, но snapshot-ы Iceberg остаются доступными до явного `expire_snapshots`.
Точный синтаксис stored procedure зависит от версии Iceberg и Spark — нужна проверка на стенде при реализации.
@@ -171,11 +172,12 @@ except Exception as e:
### Секция 6: Подготовка демо-таблицы с несколькими snapshot-ами
- **[md]** Для экспериментов с time travel и восстановлением создаём отдельную таблицу. Silver не трогаем — он нужен для Модуля 8. Создаём таблицу с небольшим подмножеством silver и наращиваем snapshot-историю через `INSERT INTO`.
- **[code]** Создание `lakehouse.default.taxi_changes_demo` через CTAS (1000 строк из silver):
- **[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 OR REPLACE TABLE lakehouse.default.taxi_changes_demo
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,
@@ -185,7 +187,7 @@ spark.sql("""
""")
```
- **[code]** Проверка: count = 1000. Snapshot-ы: 1 snapshot (overwrite — от CTAS).
- **[code]** Проверка: count = 1000. Snapshot-ы: 1 snapshot (`append` от CTAS).
- **[code]** INSERT ещё 500 строк -> snapshot 2:
```python
@@ -293,15 +295,17 @@ spark.sql(f"""
#### 8c: Что произошло со snapshot-историей
- **[code]** Snapshot-история: теперь 4 записи. Четвёртый snapshot — rollback, но его данные соответствуют snapshot 2.
- **[md]** Rollback не удаляет историю и не удаляет файлы. Он создаёт новый snapshot, указывающий на данные предыдущего состояния. Аналогия:
- **[code]** `.snapshots`: по-прежнему 3 snapshot-а. `.history`: появилась четвёртая запись, снова указывающая на snapshot 2; ошибочный snapshot помечен `is_current_ancestor = false`.
- **[md]** Rollback не удаляет историю и файлы, но и не создаёт новый snapshot. Он переключает текущее состояние на существующий snapshot-предок. Сравнение:
| | git | Iceberg |
| Свойство | `rollback_to_snapshot` | `CREATE OR REPLACE TABLE ... AS SELECT` |
|---|---|---|
| Откат с сохранением истории | `git revert` (новый коммит) | `rollback_to_snapshot` (новый snapshot) |
| Откат с удалением истории | `git reset --hard` | `CREATE OR REPLACE` (уничтожает snapshot-ы) |
| Создаёт новый snapshot | Нет | Да |
| Что становится текущим | Существующий snapshot-предок | Новый snapshot с результатом `SELECT` |
| Что происходит со старыми snapshot-ами | Сохраняются | Сохраняются для time travel |
| Цепочка предков | Возвращаемся к предку | Новый snapshot не имеет `parent_id`; прежние snapshot-ы перестают быть предками текущего |
- **[md]** Старые data files (включая «плохие» строки) остаются в MinIO до процедуры `expire_snapshots` — это тема Модуля 8. Rollback не чистит storage, он только меняет указатель текущего snapshot-а.
- **[md]** `CREATE OR REPLACE` не создаёт отдельную именованную ветку: текущей остаётся `main`. Старые data files сохраняются до процедуры `expire_snapshots` — это тема Модуля 8.
### Секция 9: RENAME COLUMN и влияние на downstream
@@ -325,7 +329,7 @@ except Exception as e:
- **[md]** Три ошибки, на которые обращает внимание PRD курса:
**Ошибка 1: Слепой `CREATE OR REPLACE`.**
В Модулях 4 и 5 мы использовали `CREATE OR REPLACE TABLE ... AS SELECT` для создания bronze и silver. Это был осознанный компромисс: первичная загрузка, нет предыдущей истории, которую нужно сохранять. Но в рабочих сценариях `CREATE OR REPLACE` — деструктивная операция: она уничтожает ВСЮ snapshot-историю таблицы. Если у таблицы было 100 snapshot-ов, после `CREATE OR REPLACE` останется один. Time travel к предыдущим состояниям станет невозможен.
В Модулях 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-системы могут интерпретировать новый тип по-другому. Перед любым изменением схемы нужно понимать, кто читает эту таблицу.
@@ -335,12 +339,13 @@ except Exception as e:
### Секция 11: Самостоятельное задание
- **[md]** Пересоздаём демо-таблицу с чистого листа. После демонстраций в Секциях 6-9 её схема изменена (RENAME), а snapshot-история содержит наши учебные эксперименты. Для самостоятельной работы нужна чистая таблица — студент сам построит snapshot-историю и выполнит rollback.
- **[code]** Пересоздание демо-таблицы (тот же CTAS, что в Секции 6):
- **[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 OR REPLACE TABLE lakehouse.default.taxi_changes_demo
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,
@@ -361,7 +366,7 @@ print(f"Демо-таблица пересоздана: {spark.table(DEMO_TABLE)
**Задача 4 (recovery).** INSERT 100 строк с `fare_amount = -1` и `pickup_zone = 'STUDENT_ERROR'` в демо-таблицу. Убедись, что «плохие» строки появились (`SELECT count(*) WHERE fare_amount < 0`). Затем выполни `rollback_to_snapshot` к snapshot-у ДО этого INSERT-а. Проверь, что таблица восстановлена: `fare_amount < 0` -> 0 строк, общий count вернулся к предыдущему значению.
**Задача 5 (ответ).** Ответь в markdown-ячейке: чем `rollback_to_snapshot` отличается от `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF <snapshot_id>`? Что происходит с историей snapshot-ов в каждом случае?
**Задача 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`
@@ -390,7 +395,7 @@ print(f"Демо-таблица пересоздана: {spark.table(DEMO_TABLE)
### Секция 13: Завершение
- **[md]** Удаляем демо-таблицу — она больше не нужна. Silver остаётся для Модуля 8 (колонка `loaded_at` — демонстрационная; Модуль 8 не зависит от её наличия).
- **[code]** `spark.sql("DROP TABLE IF EXISTS lakehouse.default.taxi_changes_demo")`.
- **[code]** `spark.sql("DROP TABLE IF EXISTS lakehouse.default.taxi_changes_demo PURGE")`.
- **[md]** Итог модуля:
- Schema evolution (ADD COLUMN) — безопасная metadata-only операция, видна из всех движков.
- Snapshot-ы — автоматическая история изменений данных, встроенная в Iceberg.
@@ -433,13 +438,14 @@ print(f"Демо-таблица пересоздана: {spark.table(DEMO_TABLE)
## Риски
- **`rollback_to_snapshot` синтаксис.** Точный синтаксис stored procedure зависит от версии Iceberg и Spark. Варианты: именованные параметры (`table => '...', snapshot_id => ...`), позиционные (`'table', snapshot_id`). Нужна проверка на стенде. Fallback: `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF <snapshot_id>` (менее элегантно, уничтожает snapshot-историю, но работает).
- **`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 через `CREATE OR REPLACE`, у silver может быть только 1 snapshot. Это нормально — `ADD COLUMN` не создаёт snapshot, но демо-таблица наращивает snapshot-историю через INSERT.
- **Time travel через Trino.** Синтаксис `FOR VERSION AS OF` может отличаться в зависимости от версии Trino. Проверить на стенде.
- **Количество 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). Это переносимый подход, не зависящий от поддержки `IF NOT EXISTS` конкретной версией Iceberg. Демо-таблица пересоздаётся через `CREATE OR REPLACE` — идемпотентна.
- **Повторный запуск.** `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
+7 -6
View File
@@ -1,7 +1,7 @@
# Модуль 8. Базовое обслуживание таблиц и финальная практика
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-08`
**Последнее обновление:** `2026-09-15`
## Цель
@@ -166,11 +166,12 @@ Greenplum AppendOnly-таблицы имеют аналогичные пробл
- **[md]** Каждый `INSERT INTO` в Iceberg создаёт новые data files. Если вставлять данные мелкими порциями (частые микробатчи, ручные INSERT-ы, инкрементальные загрузки), таблица накапливает множество мелких файлов. Это замедляет чтение: движку приходится открывать и читать каждый файл отдельно. Проблема знакома в мире Hadoop/Hive — и в Iceberg она решается через compaction.
- **[code]** Создание демо-таблицы с 500 строками (CTAS из silver):
- **[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 OR REPLACE TABLE {DEMO_TABLE}
CREATE TABLE {DEMO_TABLE}
USING iceberg AS
SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,
passenger_count, trip_distance, fare_amount, total_amount,
@@ -378,7 +379,7 @@ except Exception as e:
### Секция 8: Удаление демо-таблицы
- **[md]** Демо-таблица для обслуживания больше не нужна.
- **[code]** `spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE}")`. Вывод подтверждения.
- **[code]** `spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE")`. Вывод подтверждения.
### Секция 9: Финальная практика — сквозной end-to-end сценарий
@@ -467,7 +468,7 @@ except Exception as e:
- **Исполняемый формат:** 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 сохраняются.
- **Cleanup:** `DROP TABLE ... PURGE` для одноразовой демо-таблицы `taxi_maintenance_demo`. Финальная практика: DROP таблиц в namespace `final`, DROP namespace. Silver и bronze сохраняются.
- **Порядок:** проблема мелких файлов → compaction → expire_snapshots → Greenplum-параллель → ошибки → самостоятельное задание (обслуживание) → финальная практика (end-to-end) → checkpoint (модуль + курс) → итог курса.
## Checkpoint
@@ -505,7 +506,7 @@ except Exception as e:
- **Время выполнения 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`.
- **Повторный запуск.** Перед созданием одноразовой демо-таблицы выполняется `DROP TABLE ... PURGE`, иначе `CREATE OR REPLACE` сохранил бы прежние snapshot-ы и сломал ожидаемое число 9. В финальной практике студент должен учитывать уже существующие namespace и таблицы после прерванного запуска; cleanup в конце выполняется через `IF EXISTS`.
- **Независимость от Модуля 7.** Ноутбук не должен ссылаться на колонку `loaded_at` или результаты Модуля 7 как на обязательные предусловия. Snapshot-концепции кратко повторяются в контексте expire_snapshots.
## Out of Scope