Миграции состояния
Состояние инкарнации (incarnation.state) — это runtime-данные сервиса: список заведённых пользователей, текущие лимиты, выбранные узлы и так далее. Структура этих данных версионируется полем state_schema_version в service.yml. Когда автор сервиса меняет форму состояния (переименовал поле, поменял массив на словарь, ввёл новый лимит) — он поднимает версию схемы и описывает миграцию: преобразование состояния со старой версии на новую.
Миграция — это чистая функция «старое состояние → новое состояние». Она выполняется на стороне Keeper-а, не ходит на хосты, не имеет побочных эффектов и зависит только от старого состояния.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Сервис живёт долго: его репозиторий обновляется, у инкарнаций накапливается состояние. Если просто выкатить новую версию сервиса с другой формой состояния, старые инкарнации сломаются — их state написан по прежней схеме. Миграция приводит уже существующее состояние к новой форме при апгрейде, не требуя пересоздавать инкарнацию.
Ключевые свойства:
- Forward-only. Миграции идут только вперёд, от версии N к N+1. Обратной миграции нет — путь отката идёт через историю состояния (см. ниже).
- Атомарность. Цепочка миграций применяется одной транзакцией PostgreSQL: либо состояние полностью переходит на целевую версию, либо остаётся прежним при любой ошибке.
- Чистота. Миграция не читает секреты, не обращается к хостам и не зависит от текущего времени или параметров оператора. Один и тот же вход всегда даёт один и тот же выход — это делает миграции воспроизводимыми и тестируемыми.
Раскладка файлов
Заголовок раздела «Раскладка файлов»Миграции живут в репозитории сервиса, в каталоге migrations/. Один файл — один шаг миграции, имя файла кодирует переход между версиями:
my-service/├── service.yml # state_schema_version: 3├── migrations/│ ├── 001_to_002.yml # шаг 1 → 2│ ├── 001_to_002/│ │ └── tests/│ │ ├── users-array-to-map.yml│ │ └── empty-users.yml│ ├── 002_to_003.yml # шаг 2 → 3│ └── 002_to_003/│ └── tests/...└── ...Имя файла — NNN_to_MMM.yml: три цифры с ведущими нулями, разделитель _to_. Цепочка 001 → 002 → 003 → … прогоняется Keeper-ом последовательно при апгрейде. Рядом с каждым файлом миграции лежит одноимённый каталог с тестами (NNN_to_MMM/tests/).
DSL миграций
Заголовок раздела «DSL миграций»Файл миграции — это пара версий и список операций transform:, применяемых по порядку. Каждая операция видит состояние, уже мутированное предыдущими операциями этой же миграции.
from_version: 1to_version: 2
description: > Переход с массива users[] на словарь users{name: {acl, enabled}} для поддержки per-user ACL и флага enabled/disabled.
transform: # Переименовать поле, чтобы освободить имя под новую форму. - rename: { from: state.users, to: state.users_legacy }
# CEL-выражение в значении: пересчитать лимит из МБ в байты. - set: path: state.maxmemory_bytes value: "${ int(state.maxmemory_mb) * 1048576 }"
- delete: { path: state.maxmemory_mb }
# Итерация по старому списку: для каждого имени завести запись в словаре. - foreach: "${ state.users_legacy }" as: user_name do: - set: path: "state.users.${ user_name }" value: acl: "off ~* &* +@all" enabled: false
- delete: { path: state.users_legacy }Операции transform:
Заголовок раздела «Операции transform:»| Операция | Параметры | Семантика |
|---|---|---|
rename | from: <path>, to: <path> | Переместить значение из from в to. Если to уже существует — ошибка (сделайте delete перед rename). |
set | path: <path>, value: <yaml> либо <CEL> | Записать value в path. Существующий ключ перезаписывается. value — литерал YAML (скаляр/список/словарь), CEL-выражение через ${ … }, либо вложенная структура со встроенными ${ … }-интерполяциями. |
delete | path: <path> | Удалить значение по path. Если значения нет — это не ошибка, операция ничего не делает. |
move | from: <path>, to: <path> | То же, что rename. |
foreach | in: <CEL> (или краткая форма foreach: <CEL>), as: <var>, do: [<operation>, …] | Структурный цикл по списку или значениям словаря. На каждом шаге <var> биндится к текущему элементу, do: — вложенный список операций. Внутри do: доступны и <var>, и весь текущий state. |
Адресация — path:
Заголовок раздела «Адресация — path:»Точечная нотация от корня состояния:
- Префикс
state.обязателен — это явное указание области. - Вложенность пишется через точку:
state.limits.maxmemory. - Сегмент пути может быть интерполяцией:
state.users.${ user_name }.acl— имя сегмента вычисляется CEL-выражением.
CEL в миграциях
Заголовок раздела «CEL в миграциях»Любое значение в set.value, в foreach.in и в сегментах path: может быть CEL-выражением через маркер ${ … }. Доступны стандартные операторы и встроенные функции CEL (int, string, bool, size, has, keys, values, comprehensions map/filter/all/exists).
Контекст выражений намеренно узкий — миграция должна быть чистой функцией от старого состояния:
| Доступно | Что это |
|---|---|
state | Текущее состояние, мутируемое по ходу операций. Корень — incarnation.state. |
<as> внутри foreach.do | Текущий элемент итерации: значение словаря или элемент списка. |
| Запрещено | Почему |
|---|---|
vault(…) | Миграция не тянет секреты. |
now() | Воспроизводимость: один вход → один выход, иначе тесты не детерминированы. |
register.* | Нет хост-контекста — миграция выполняется на Keeper-е, а не на хосте. |
soulprint.* | То же: фактов хоста при миграции нет. |
essence.* | Миграция зависит только от старого состояния, а не от текущих параметров сервиса. |
input.* | Миграция не принимает параметров оператора. |
Эта песочница — часть дизайна, а не временное ограничение: благодаря ей результат миграции зависит ровно от одного входа — старого состояния.
Апгрейд инкарнации
Заголовок раздела «Апгрейд инкарнации»Переход инкарнации на новую версию схемы — явный шаг оператора, а не автоматическое «ленивое» обновление при первом обращении. Оператор инициирует апгрейд до целевой версии (keeper.incarnation.upgrade name=<инкарнация> to_version=<N>), и Keeper выполняет цепочку миграций в одной транзакции PostgreSQL:
BEGIN.- Прочитать текущие
stateиstate_schema_versionинкарнации под блокировкой строки. - Применить миграции последовательно в памяти:
state_v1 → state_v2 → state_v3 → …до целевой версии. - На каждом шаге записать snapshot в историю состояния (
state_history): что было до и что стало после, с пометкой, что это миграция. - Обновить
state,state_schema_versionи версию сервиса инкарнации. COMMIT.
Если любой шаг падает — ROLLBACK: состояние остаётся прежним, инкарнация помечается статусом ошибки миграции, апгрейд не считается выполненным. Полностью или ничего.
Поскольку миграции forward-only, путь восстановления после неудачного или нежелательного апгрейда — история состояния (state_history), где лежит snapshot прежнего состояния перед каждым изменением.
Тесты миграций
Заголовок раздела «Тесты миграций»У каждой миграции есть тесты — они лежат в migrations/<NNN_to_MMM>/tests/<case>.yml. Тест задаёт состояние до миграции и ожидаемое состояние после; раннер применяет миграцию и сверяет результат на полное совпадение.
name: users-array-to-mapdescription: > Базовый случай: массив имён переходит в словарь с per-user ACL.
state_before: users: ["app", "monitor"] maxmemory_mb: 512
state_after: users: app: { acl: "off ~* &* +@all", enabled: false } monitor: { acl: "off ~* &* +@all", enabled: false } maxmemory_bytes: 536870912Что делает раннер:
- загружает
state_beforeкакstate; - применяет операции
transform:из файла миграции; - сверяет получившийся
stateсstate_after(полное, глубокое сравнение).
Стоит покрывать тестами не только «нормальный» случай, но и граничные: пустой массив, отсутствующее опциональное поле, уже мигрированное частично состояние. Тесты исполняются офлайн, ничего не применяя на хостах.
Примеры
Заголовок раздела «Примеры»Переименование поля
Заголовок раздела «Переименование поля»Поле max_clients переименовано в max_connections без изменения значения:
from_version: 3to_version: 4
description: Переименование max_clients → max_connections.
transform: - rename: { from: state.max_clients, to: state.max_connections }Тест:
name: rename-max-clientsstate_before: max_clients: 1000state_after: max_connections: 1000foreach по массиву
Заголовок раздела «foreach по массиву»Старое состояние хранило ноды как список строк-имён; новая схема — список словарей с дополнительным полем weight по умолчанию:
from_version: 5to_version: 6
description: nodes[] из списка имён → список объектов {name, weight}.
transform: - rename: { from: state.nodes, to: state.nodes_legacy } - set: { path: state.nodes, value: [] }
- foreach: "${ state.nodes_legacy }" as: node_name do: - set: path: "state.nodes_by_name.${ node_name }" value: name: "${ node_name }" weight: 100
- delete: { path: state.nodes_legacy }Тест:
name: nodes-list-to-objectsstate_before: nodes: ["node-a", "node-b"]state_after: nodes: [] nodes_by_name: node-a: { name: "node-a", weight: 100 } node-b: { name: "node-b", weight: 100 }Что дальше
Заголовок раздела «Что дальше»- Scenario — где объявляется, что сценарий пишет в
incarnation.state(блокstate_changes). - Обзор DSL — как связаны Destiny, Scenario и Essence; модель шаблонизатора и CEL.
- Эксплуатация → Обновление инкарнации — апгрейд состояния с точки зрения эксплуатации.