fix(course): clarify history and fix snapshot expiration
Set an explicit expiration cutoff because retain_last alone preserves fresh snapshots under the default five-day age threshold. Add result checks, distinguish time travel from rollback, and explain schema metadata versions. Validated changed cells with Spark 3.5.1 and Iceberg 1.10.0 in an isolated container: 10 to 2 snapshots, 1300 rows preserved, expired time travel rejected, repeat execution in UTC and America/New_York. Notebook validation, Python syntax, local links and diff checks pass.
This commit is contained in:
1 parent
872b876be1
commit
bf3241124c
4 files changed
+57
-22
No files matched your search
@@ -315,9 +315,15 @@
|
||||
"source": [
|
||||
"### Секция 4: expire_snapshots — очистка истории\n",
|
||||
"\n",
|
||||
"`expire_snapshots` удаляет старые snapshot-ы из метаданных. \n",
|
||||
"**Важный компромисс:** после expire_snapshots time travel к удалённым snapshot-ам невозможен. Это необратимо. \n",
|
||||
"В production нужно решить: сколько истории хранить? Обычно 10–30 дней. Для демо оставим только 2 последних snapshot-а.\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"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -328,12 +334,14 @@
|
||||
"source": [
|
||||
"# Snapshot-история ДО expire\n",
|
||||
"snapshots_before = spark.sql(f\"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots\")\n",
|
||||
"print(f\"Snapshot-ов до expire: {snapshots_before.count()}\")\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"
|
||||
]
|
||||
},
|
||||
@@ -343,10 +351,16 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Выполняем expire_snapshots (оставляем только 2 последних)\n",
|
||||
"# Получаем границу времени в часовом поясе 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",
|
||||
@@ -362,8 +376,16 @@
|
||||
"source": [
|
||||
"# Snapshot-история ПОСЛЕ expire\n",
|
||||
"snapshots_after = spark.sql(f\"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots\")\n",
|
||||
"print(f\"Snapshot-ов после expire: {snapshots_after.count()}\")\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"
|
||||
]
|
||||
},
|
||||
@@ -385,10 +407,11 @@
|
||||
" SELECT count(*) FROM {DEMO_TABLE}\n",
|
||||
" VERSION AS OF {first_snapshot_id}\n",
|
||||
" \"\"\").show()\n",
|
||||
" print(\"Time travel сработал (snapshot не был удалён).\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"Ожидаемая ошибка: {e}\")\n",
|
||||
" print(\"Snapshot удалён — time travel невозможен.\")\n",
|
||||
"else:\n",
|
||||
" raise AssertionError(\"Time travel сработал: старый snapshot не был удалён.\")\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
@@ -398,7 +421,9 @@
|
||||
"source": [
|
||||
"**Вывод:** expire нужно запускать обдуманно. Слишком агрессивно (потеря страховки для rollback), слишком редко (рост метаданных и стоимости хранения).\n",
|
||||
"\n",
|
||||
"*Примечание: физическое удаление файлов из MinIO зависит от конфигурации. Иногда требуется дополнительно запускать процедуру `remove_orphan_files`, которая физически чистит файлы без привязки к активным snapshot-ам.*\n"
|
||||
"В нашем демо сохранены snapshot после compaction и его предок до compaction. Предку ещё нужны старые мелкие файлы, поэтому `expire_snapshots` их не удаляет. Удаление snapshot-ов не обязательно освобождает data files: важны ссылки из оставшихся состояний.\n",
|
||||
"\n",
|
||||
"`remove_orphan_files` решает другую задачу: удаляет файлы, на которые вообще нет ссылок в метаданных таблицы, например остатки неудачной записи. Для файлов, нужных сохранённым snapshot-ам, она не подходит.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -414,11 +439,11 @@
|
||||
"| 2 | `expire_snapshots` | Удаляет старые snapshot-ы | Метаданные и хранилище растут |\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"Если сделать наоборот: expire удалит snapshot-ы, но мелкие файлы останутся (так как на них всё ещё ссылается текущий snapshot). Compaction затем создаст новые крупные файлы, а старые мелкие станут \"orphan\" (сиротами) и не удалятся автоматически.\n",
|
||||
"Если выполнить expire до compaction, мелкие файлы останутся: на них ссылается текущий snapshot. После compaction они будут нужны его предку. Удалить их сможет следующий вызов `expire_snapshots`, когда этот предок попадёт под условия очистки. До этого файлы не являются «сиротами».\n",
|
||||
"\n",
|
||||
"**Типичные ошибки:**\n",
|
||||
"1. **Никогда не запускать compaction:** таблица обрастает тысячами мелких файлов, чтение деградирует в десятки раз.\n",
|
||||
"2. **`expire_snapshots` сразу после ошибки (с `retain_last => 1`):** если записали плохие данные и запустили expire, откатиться назад (rollback) уже не выйдет. Правило: перед expire убедись, что текущее состояние корректно.\n",
|
||||
"2. **Удалить исправный snapshot после ошибочной записи:** если `older_than` и `retain_last` допускают его удаление, после expire откатиться к нему уже не выйдет. Правило: перед expire убедись, что текущее состояние корректно.\n",
|
||||
"3. **Обслуживание на каждый INSERT:** compaction — тяжёлая операция. В production его делают по расписанию (например, 1 раз в сутки ночью).\n"
|
||||
]
|
||||
},
|
||||
@@ -455,8 +480,8 @@
|
||||
"- Создай таблицу `lakehouse.default.taxi_compaction_practice` через CTAS (500 строк из `silver`). \n",
|
||||
"- Выполни 5 INSERT INTO по 200 строк. \n",
|
||||
"- Посмотри файловую статистику ДО.\n",
|
||||
"- Выполни compaction и expire_snapshots (retain_last => 1). \n",
|
||||
"- Проверь результат ПОСЛЕ. \n",
|
||||
"- Выполни compaction, затем получи свежий `cutoff` по образцу Секции 4 и вызови `expire_snapshots` с `older_than => TIMESTAMP '{cutoff}'` и `retain_last => 1`.\n",
|
||||
"- Проверь результат ПОСЛЕ: остался 1 snapshot, число строк не изменилось.\n",
|
||||
"- Удали таблицу (`DROP TABLE`).\n"
|
||||
]
|
||||
},
|
||||
@@ -565,7 +590,7 @@
|
||||
"\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`), затем `expire_snapshots` (retain_last => 1). Проверь, что файлов стало меньше, а snapshot-ов остался 1.\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"
|
||||
|
||||
Reference in new issue
Block a user