Time travel: FOR VERSION AS OF, FOR TIMESTAMP AS OF, branches и tags
В прошлом уроке мы установили ключевой факт: снапшот Iceberg неизменяем, а новое изменение не правит старый снапшот, а создаёт новый. Старые снапшоты остаются жить в таблице. Из этого свойства бесплатно вытекает мощная возможность — time travel: запрос может явно обратиться к любому прошлому состоянию таблицы. В этом уроке разберём оба синтаксиса time travel в Trino, увидим, как откатить таблицу к прошлому снапшоту, и познакомимся с branches и tags — именованными указателями на снапшоты.
Откуда берётся time travel
Time travel — не отдельная «фича», которую кто-то специально встроил. Это прямое следствие архитектуры из урока 3. Каждый INSERT, UPDATE, DELETE, MERGE создаёт новый снапшот, а metadata-файл хранит список всех снапшотов таблицы. Пока снапшот не удалён процедурой expire_snapshots, все его файлы данных, манифесты и manifest list на месте и образуют согласованный, читаемый срез таблицы.
Обычный SELECT без указания версии читает текущий снапшот — тот, что помечен актуальным. Time travel — это просто способ сказать движку: «возьми не текущий снапшот, а вот этот конкретный». Trino поддерживает два способа адресовать снапшот: по идентификатору и по времени.
FOR TIMESTAMP AS OF: адресация по времени
Первый синтаксис — FOR TIMESTAMP AS OF. Он отвечает на вопрос «какой была таблица в такой-то момент». Trino находит снапшот, который был текущим на указанный момент времени: самый поздний снапшот с committed_at не позже заданного timestamp.
-- Состояние таблицы на конкретный момент в прошлом
SELECT count(*) AS orders, sum(amount) AS revenue
FROM iceberg.sales.orders
FOR TIMESTAMP AS OF TIMESTAMP '2026-05-19 12:00:00 UTC';
-- orders | revenue
-- --------+----------
-- 18 | 4250.00
Это удобно для расследований и аудита: «сколько было заказов на конец вчерашнего дня», «как выглядел отчёт до утреннего обновления». Важная тонкость: момент должен попадать в окно жизни снапшота. Если запросить timestamp раньше первого снапшота — Trino вернёт ошибку, такого состояния не существует. Если запросить timestamp в окне снапшота, который уже expired, — снапшота нет, ошибка. Time travel ограничен историей, которую таблица реально хранит.
Разберём механику разрешения timestamp точнее, потому что здесь легко ошибиться. У каждого снапшота есть момент фиксации committed_at. Снапшоты выстроены в линию по этому моменту. Запрос FOR TIMESTAMP AS OF T заставляет Trino найти снапшот, который был актуальным в момент T, — то есть последний снапшот с committed_at <= T. Если снапшот 2 зафиксирован в 11:00, а снапшот 3 — в 12:00, то любой timestamp от 11:00 включительно и до 12:00 не включительно «попадает» в снапшот 2: в этот промежуток времени именно снапшот 2 был головой таблицы. Запрос с timestamp 11:30 и запрос с timestamp 11:59 вернут идентичный результат — оба адресуют снапшот 2. Это логично: между двумя коммитами таблица не менялась, поэтому весь интервал между ними соответствует одному состоянию.
Отсюда практическое следствие: FOR TIMESTAMP AS OF отвечает на вопрос «как выглядела таблица в этот момент», а не «дай мне снапшот, созданный ровно в это время». Если нужен конкретный снапшот, а не состояние на момент, надёжнее адресовать его по идентификатору — это следующий синтаксис. Ещё одна деталь: timestamp в Trino несёт информацию о временной зоне, и при time travel это важно — TIMESTAMP '2026-05-19 12:00:00 UTC' и тот же момент в другой зоне разрешатся в один и тот же снапшот, но запись timestamp без явной зоны интерпретируется в зоне сессии, что при расследованиях через границы суток может ввести в заблуждение. В аудиторских запросах зону указывают явно.
FOR VERSION AS OF: адресация по идентификатору
Второй синтаксис — FOR VERSION AS OF. Он адресует снапшот по его точному идентификатору. Идентификаторы снапшотов видны в metadata-таблице $snapshots:
SELECT snapshot_id, committed_at, operation
FROM iceberg.sales."orders$snapshots"
ORDER BY committed_at;
-- snapshot_id | committed_at | operation
-- --------------------+----------------------------+-----------
-- 6841730300912934 | 2026-05-19 10:00:12.482 UTC | append
-- 7720118574000021 | 2026-05-19 11:00:03.115 UTC | append
-- 2287709004455187 | 2026-05-19 12:00:41.870 UTC | delete
-- 9920118574521663 | 2026-05-19 13:00:55.204 UTC | append
-- Точное обращение к снапшоту по его id
SELECT count(*) AS orders
FROM iceberg.sales.orders FOR VERSION AS OF 7720118574000021;
-- orders
-- --------
-- 11
FOR VERSION AS OF точнее, чем FOR TIMESTAMP AS OF: timestamp может попасть «между» двумя снапшотами и неоднозначно, а id адресует ровно один снапшот. Используйте version, когда нужна воспроизводимость: запрос с конкретным snapshot_id всегда вернёт один и тот же результат, пока этот снапшот не expired. FOR VERSION AS OF принимает не только id снапшота, но и имя ветки или тега — об этом ниже.
Практический приём отладки пайплайна. Если витрина внезапно показывает неверные числа, сравните её текущее состояние с состоянием до подозрительного запуска: SELECT * FROM mart FOR VERSION AS OF <snapshot_до> против обычного SELECT * FROM mart. Diff покажет, что именно изменил сбойный запуск, без восстановления из бэкапа.
Откат таблицы: rollback_to_snapshot
Time travel читает прошлое. Иногда нужно не просто прочитать, а вернуть таблицу в прошлое состояние — например, сбойный пайплайн записал испорченные данные. Для этого есть процедура rollback_to_snapshot.
-- Откатить таблицу к состоянию на снапшоте до сбоя
CALL iceberg.system.rollback_to_snapshot(
schema_name => 'sales',
table_name => 'orders',
snapshot_id => 7720118574000021
);
Важно понять механику: rollback не удаляет снапшоты, появившиеся после целевого. Он создаёт новый снапшот, чьё содержимое совпадает с целевым. История продолжает расти вперёд — просто новая «голова» таблицы указывает на данные старого снапшота. Это согласуется с принципом неизменяемости: ничего не стирается, добавляется новое состояние. Поэтому откат сам по себе обратим — можно сделать time travel к снапшоту, который был до отката.
Branches и tags: именованные указатели на снапшоты
Адресовать снапшот сырым 16-значным id неудобно и нечитаемо. Iceberg даёт именованные ссылки на снапшоты — branches (ветки) и tags (теги). Идея та же, что в Git: человекочитаемое имя вместо хеша.
Tag — неподвижный именованный указатель на один конкретный снапшот. Создали тег eom-2026-04 на снапшоте конца апреля — и он навсегда указывает на это состояние. Теги нужны для закрепления значимых точек: конец отчётного периода, состояние перед крупной миграцией, «золотой» срез для регрессионных тестов.
Branch — подвижный именованный указатель. В отличие от тега, ветка движется: запись в ветку создаёт новый снапшот, и указатель ветки переходит на него. У таблицы всегда есть ветка main — это и есть та «голова», которую читает обычный SELECT. Дополнительные ветки позволяют вести параллельные линии изменений: например, ветка staging для проверки нового пайплайна, не затрагивая main.
Branches и tags создаются через ALTER TABLE ... EXECUTE:
-- Создать тег на текущем снапшоте таблицы
ALTER TABLE iceberg.sales.orders
EXECUTE create_tag(tag_name => 'eom-2026-04');
-- Создать ветку
ALTER TABLE iceberg.sales.orders
EXECUTE create_branch(branch_name => 'staging');
Обращаться к ветке или тегу можно через FOR VERSION AS OF с именем — читаемая альтернатива числовому id:
-- Чтение по имени тега вместо сырого snapshot_id
SELECT count(*) AS orders_at_eom
FROM iceberg.sales.orders FOR VERSION AS OF 'eom-2026-04';
-- orders_at_eom
-- ---------------
-- 1240
Все ветки и теги таблицы видны в metadata-таблице $refs:
SELECT name, type, snapshot_id
FROM iceberg.sales."orders$refs";
-- name | type | snapshot_id
-- ---------------+--------+------------------
-- main | BRANCH | 9920118574521663
-- staging | BRANCH | 9920118574521663
-- eom-2026-04 | TAG | 6841730300912934
Зачем ветки нужны на практике
Branches — это не «фича на всякий случай», у них есть конкретные инженерные применения, и понимать их полезно даже на middle-уровне.
Первое — write-audit-publish, паттерн «записать, проверить, опубликовать». Прямая запись в боевую таблицу main означает, что некачественные данные сразу видны всем потребителям отчётов. Ветка разрывает этот риск. Пайплайн пишет новую порцию данных не в main, а в отдельную ветку, например audit. Пока данные в ветке, на ней прогоняются проверки качества — обычными SQL-запросами к этой ветке. Только если проверки прошли, изменения переносят в main. Потребители всё это время видят прежний, проверенный main; брак до них не доходит. Это перенос идеи «не мержить непроверенный код» из разработки на данные.
Второе — изоляция экспериментов. Нужно проверить, как на таблицу повлияет крупная переработка пайплайна, но без риска для боевых данных. Ветка experiment даёт песочницу: вы прогоняете новую логику в ветке, сравниваете результат с main, и если эксперимент неудачен — просто отбрасываете ветку, main не затронут.
Принципиальное отличие ветки от тега ещё раз: тег фиксирует прошлое (закрепляет состояние, которое уже было), ветка ведёт параллельное настоящее (отдельную живую линию изменений). Тег — это закладка в истории, ветка — это альтернативная голова.
Запись в конкретную ветку и продвинутые операции с ветками (например, перенос изменений между ветками) в экосистеме Iceberg часто выполняются через Spark-процедуры, а полнота поддержки веток на запись в Trino зависит от версии. Для аналитика на Trino важнее уверенно владеть чтением: FOR VERSION AS OF 'имя-ветки-или-тега' и таблицей $refs. Перед тем как строить write-audit-publish именно на Trino, сверьтесь с release notes вашей версии — какие операции с ветками доступны.
Time travel и ветки работают только пока снапшот жив. Процедура expire_snapshots (следующий урок) удаляет старые снапшоты и их файлы — после этого обратиться к ним нельзя. Если снапшот закреплён тегом или является головой ветки, expire его не тронет: ref защищает снапшот от удаления. Поэтому тег — это ещё и способ гарантировать, что важное состояние переживёт обслуживание таблицы.
Попробуй сам
В песочнице создайте iceberg.sales.orders и сделайте серию изменений с паузами: INSERT, затем INSERT, затем DELETE части строк, затем ещё INSERT. Откройте orders$snapshots, выпишите все snapshot_id и committed_at. Теперь упражнения. Первое: выполните SELECT count(*) с FOR VERSION AS OF для каждого снапшота и проследите, как менялось число строк по истории. Второе: возьмите committed_at второго снапшота, выполните FOR TIMESTAMP AS OF чуть позже этого времени и убедитесь, что результат совпал с FOR VERSION AS OF второго снапшота. Третье: создайте тег before-delete на снапшоте, который был перед DELETE, затем выполните rollback_to_snapshot к этому снапшоту. Проверьте orders$snapshots — сколько теперь снапшотов и почему их число выросло, а не уменьшилось. Объясните письменно, почему откат не стирает историю.