Перейти к содержимому

Миграции состояния

Состояние инкарнации (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/).

Файл миграции — это пара версий и список операций transform:, применяемых по порядку. Каждая операция видит состояние, уже мутированное предыдущими операциями этой же миграции.

from_version: 1
to_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 }
ОперацияПараметрыСемантика
renamefrom: <path>, to: <path>Переместить значение из from в to. Если to уже существует — ошибка (сделайте delete перед rename).
setpath: <path>, value: <yaml> либо <CEL>Записать value в path. Существующий ключ перезаписывается. value — литерал YAML (скаляр/список/словарь), CEL-выражение через ${ … }, либо вложенная структура со встроенными ${ … }-интерполяциями.
deletepath: <path>Удалить значение по path. Если значения нет — это не ошибка, операция ничего не делает.
movefrom: <path>, to: <path>То же, что rename.
foreachin: <CEL> (или краткая форма foreach: <CEL>), as: <var>, do: [<operation>, …]Структурный цикл по списку или значениям словаря. На каждом шаге <var> биндится к текущему элементу, do: — вложенный список операций. Внутри do: доступны и <var>, и весь текущий state.

Точечная нотация от корня состояния:

  • Префикс state. обязателен — это явное указание области.
  • Вложенность пишется через точку: state.limits.maxmemory.
  • Сегмент пути может быть интерполяцией: state.users.${ user_name }.acl — имя сегмента вычисляется 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:

  1. BEGIN.
  2. Прочитать текущие state и state_schema_version инкарнации под блокировкой строки.
  3. Применить миграции последовательно в памяти: state_v1 → state_v2 → state_v3 → … до целевой версии.
  4. На каждом шаге записать snapshot в историю состояния (state_history): что было до и что стало после, с пометкой, что это миграция.
  5. Обновить state, state_schema_version и версию сервиса инкарнации.
  6. COMMIT.

Если любой шаг падает — ROLLBACK: состояние остаётся прежним, инкарнация помечается статусом ошибки миграции, апгрейд не считается выполненным. Полностью или ничего.

Поскольку миграции forward-only, путь восстановления после неудачного или нежелательного апгрейда — история состояния (state_history), где лежит snapshot прежнего состояния перед каждым изменением.

У каждой миграции есть тесты — они лежат в migrations/<NNN_to_MMM>/tests/<case>.yml. Тест задаёт состояние до миграции и ожидаемое состояние после; раннер применяет миграцию и сверяет результат на полное совпадение.

name: users-array-to-map
description: >
Базовый случай: массив имён переходит в словарь с 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

Что делает раннер:

  1. загружает state_before как state;
  2. применяет операции transform: из файла миграции;
  3. сверяет получившийся state с state_after (полное, глубокое сравнение).

Стоит покрывать тестами не только «нормальный» случай, но и граничные: пустой массив, отсутствующее опциональное поле, уже мигрированное частично состояние. Тесты исполняются офлайн, ничего не применяя на хостах.

Поле max_clients переименовано в max_connections без изменения значения:

from_version: 3
to_version: 4
description: Переименование max_clients → max_connections.
transform:
- rename: { from: state.max_clients, to: state.max_connections }

Тест:

name: rename-max-clients
state_before:
max_clients: 1000
state_after:
max_connections: 1000

Старое состояние хранило ноды как список строк-имён; новая схема — список словарей с дополнительным полем weight по умолчанию:

from_version: 5
to_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-objects
state_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 }