# Безопасные процессы миграции баз данных для AI-агентов программирования

Изменения в продакшен-базе данных требуют другого процесса, чем обычные правки кода. AI-агент для программирования может изучить схему, проследить за запросами приложения и подготовить файлы миграции быстрее, чем большинство команд успевает назначить ревью. Но эта скорость не должна давать ему право менять продакшен только потому, что в промпте случайно встретилось слово «deploy».

Безопасный процесс миграции базы данных разделяет планирование и полномочия. Агент может подготовить факты и точный набор изменений. Человек проверяет операционные последствия, подтверждает работоспособность восстановления и одобряет один ограниченный запуск. Такое разделение предотвращает знакомую ситуацию, когда безобидный на вид `ALTER TABLE` блокирует оформление заказов, запускает неконтролируемую переработку данных или приводит к незавершённому развёртыванию, которое никто не может объяснить.

В этой статье используются команды PostgreSQL, поскольку поведение блокировок и транзакций подробно описано в документации. Процесс подходит и для других реляционных баз данных, но не переносите синтаксис и предположения PostgreSQL в другой движок, не сверившись с его руководством.

## Для изменений в продакшене нужны отдельные полномочия

Файл миграции и миграция в продакшене это разные действия. Первый описывает намерение. Вторая использует ограниченные полномочия в работающей системе с активными пользователями, репликацией, резервными копиями и версиями приложения, которые могут не совпадать.

Команды часто совершают ошибку и выдают агенту одно широкое подключение к базе, потому что так проще разрабатывать локально. Затем это подключение пересекает границы окружений, и агент не может отличить staging-хост от продакшен-кластера, если оба принимают одинаковые учётные данные. Ещё хуже, когда агент может выполнять произвольный SQL: у него нет естественной причины остановиться перед переработкой таблицы. Он видит задачу и инструмент.

Разделите работу на этапы с разными входными данными и разрешениями:

1. Исследование читает метаданные схемы, историю миграций, код запросов и операционные ограничения.
2. Планирование создаёт SQL, описывает ожидаемое поведение блокировок, влияние на данные, preflight-проверки, запросы для проверки и решение по восстановлению.
3. Ревью подтверждает, что план соответствует фактическому состоянию продакшена и что организация принимает связанный с ним риск.
4. Выполнение запускает один одобренный артефакт для одного явно указанного target.
5. Проверка подтверждает, что приложение и база данных достигли нужного состояния, прежде чем кто-либо объявит развёртывание завершённым.

Важно различать *обратимость* и *восстанавливаемость*. Добавление nullable-колонки обычно обратимо: позднее её можно удалить, если код от неё не зависит. Обновление миллионов строк по ошибочному выражению может быть необратимым, даже если кто-то напишет похожий обратный запрос. Исходные значения могли быть перезаписаны, и узнать их уже нельзя. Восстанавливаемость означает, что у вас есть проверенный способ восстановить систему или ограничить ущерб. В каждом плане миграции указывайте эти свойства отдельно.

Агент не должен выводить полномочия для продакшена из ветки репозитория, метки задачи или переменной окружения, переданной в промпте. Эти сигналы описывают намерение, а намерение часто бывает ошибочным. Для выполнения нужны target, выбранный вне текстового контекста агента, подготовленный артефакт с записанным digest и человек, который видит точную команду.

## Сделайте план миграции артефактом для ревью

Хороший план даёт оператору достаточно информации, чтобы отклонить миграцию до её попадания в базу. «Добавить индекс» это не план. Размер таблицы, цель запроса, форма индекса, ограничения транзакций, влияние блокировок и запрос для проверки превращают эту фразу в план.

Попросите агента создавать каталог с фиксированными файлами, а не абзац в pull request. Например:

```text
migrations/2025-04-add-orders-status-index/
  up.sql
  verify.sql
  preflight.sql
  recovery.md
  manifest.json
```

Manifest должен связывать файлы с нужным target и описывать то, что выяснил агент. Этот пример не раскрывает учётные данные и не утверждает, что резервная копия существует, только потому, что кто-то её запросил.

```json
{
  "migration_id": "2025-04-add-orders-status-index",
  "engine": "postgresql",
  "target": "production-orders",
  "change": "create index for filtered order status query",
  "transaction_mode": "outside_transaction",
  "requires_backup_restore_check": true,
  "expected_write_blocking": "none during index build",
  "stop_conditions": [
    "target schema differs from preflight result",
    "backup restore check fails",
    "index is invalid after execution"
  ]
}
```

По этому артефакту ревьюер должен ответить на пять вопросов:

- Какая база данных и схема получат изменение?
- Какой точный SQL выполнится и обернёт ли runner его в транзакцию?
- Какая операция будет блокировать, перерабатывать, сканировать данные или занимать дополнительное место?
- Какие факты подтверждают, что базу можно восстановить, если изменение пойдёт не так?
- Какие запросы или сигналы приложения докажут успех?

Требуйте от агента указывать неопределённость. Если он не может установить версию PostgreSQL, размер таблицы, текущую версию миграций или диапазон совместимости вызывающего приложения, это должно стать условием остановки. Выдуманная уверенность по неполному доступу к репозиторию хуже незаполненного пункта.

После ревью не изменяйте сгенерированный SQL. Если ревьюер редактирует `up.sql`, пересчитайте checksum и снова отправьте артефакт на проверку. Типичный сбой начинается с одобренной миграции, после чего в deployment shell в спешке появляется «небольшое исправление». Живая команда уже не совпадает с проверенной, а аудит рассказывает успокаивающую, но ложную историю.

## Классифицируйте операцию до выбора способа запуска

Само SQL-слово не говорит достаточно об операционном риске. `ALTER TABLE` включает и быстрые изменения, и операции, которые берут блокировки или перерабатывают столько данных, что заканчивается место на диске. Безопасный процесс учитывает фактическую операцию, версию базы, размер таблицы и параллельную нагрузку.

Документация PostgreSQL для `ALTER TABLE` прямо описывает неприятную часть: многие формы команды получают блокировку `ACCESS EXCLUSIVE`, если в руководстве не указано иное. Эта блокировка конфликтует с чтением и записью. Фраза «в staging всё прошло быстро» мало что значит, если там нет долгих транзакций, отчётов и сопоставимого объёма данных.

Используйте четыре практических класса:

| Класс | Типичный пример | Ожидаемый способ выполнения |
|---|---|---|
| Только метаданные | Добавить nullable-колонку без значения по умолчанию | Быстрая операция, но поведение блокировок нужно проверить для вашей версии |
| Конкурентное построение | Создать новый индекс | Специальная форма команды и отдельная работа с транзакцией |
| Пакетное изменение данных | Заполнить новую колонку | Небольшие коммиты, измеряемая скорость, возобновляемый прогресс |
| Переработка или разрушительное изменение | Изменить тип большой колонки или удалить данные | Решение для окна обслуживания с явным планом восстановления |

Не называйте безопасной любую операцию, которая проходит онлайн. `CREATE INDEX CONCURRENTLY` в PostgreSQL не блокирует записи во время построения, как указано в документации `CREATE INDEX`. Но команда выполняется дольше, не может работать внутри блока транзакции и после ошибки может оставить некорректный индекс. Совет «всегда используйте concurrently» не учитывает эти условия. Применяйте команду, когда важна доступность записи и ваш migration runner умеет соблюдать её правила.

Запрос для планирования даст ревьюеру предварительную оценку размера таблицы и индексов:

```sql
SELECT
  pg_size_pretty(pg_total_relation_size('public.orders')) AS total_size,
  pg_size_pretty(pg_relation_size('public.orders')) AS table_size,
  pg_size_pretty(pg_indexes_size('public.orders')) AS indexes_size;
```

Ожидаемый результат содержит одну строку с тремя читаемыми значениями размера. Считайте это ориентиром, а не обещанием по длительности. Ширина строк, состояние кэша, параллельные записи, репликация, скорость диска и активные транзакции меняют результат.

Перед операцией с заметным влиянием блокировок агенту следует также проверить блокирующие процессы. PostgreSQL показывает активные сессии в `pg_stat_activity`, но это не даёт разрешения завершать их. План должен называть владельца длительной операции и указывать, будет ли миграция ждать, перенесена или выполнена в окно обслуживания.

## Восстановленная резервная копия даёт право на вход

Успешная команда резервного копирования доказывает, что программа записала файл. Она не доказывает, что файл восстановится, содержит нужные объекты и что процедура восстановления сработает в условиях давления. Перед разрушительной или труднообратимой операцией выполняйте проверку восстановления.

Для базы PostgreSQL логический дамп в пользовательском формате может выглядеть так:

```sh
pg_dump -Fc -d "$SOURCE_DATABASE" -f "orders-preflight.dump"
pg_restore -l "orders-preflight.dump" | sed -n '1,12p'
createdb migration_restore_check
pg_restore -d migration_restore_check "orders-preflight.dump"
psql -d migration_restore_check -c "SELECT count(*) FROM public.orders;"
```

Команда просмотра должна вывести элементы архива, например определение таблицы, запись с данными таблицы и определения индексов. Последний запрос должен напечатать результат в одну колонку с количеством восстановленных строк. Запишите результат команд, идентификатор дампа, target восстановления и результат проверки в запись миграции. Не добавляйте в неё URL баз данных и пароли.

Проверка логического восстановления не заменяет физический тест восстановления, восстановление на момент времени, тренировку переключения на реплику или гарантию резервного копирования от провайдера. Она отвечает на более узкий вопрос: можно ли восстановить этот дамп в базу и получить ожидаемые объекты и структуру данных? Даже такой узкий ответ выявляет повреждённые архивы, отсутствующие расширения, проблемы ролей и процедуры, которые работали только на ноутбуке автора.

Выбирайте способ восстановления до выполнения, а не после ошибки. В плане должно быть указано, какой вариант применяется:

- Обратная миграция безопасна, потому что данные не удалялись и приложение допускает возврат.
- Восстановление в заменяющую базу, при этом назван владелец решения о переключении.
- Используется процедура восстановления реплики или провайдера, для неё задокументирована целевая точка восстановления.
- Изменение нельзя корректно восстановить, поэтому нужны окно обслуживания и явное принятие риска.

Последняя категория вполне законна. Нельзя делать вид, что откат существует, только потому, что deployment template требует заполнить такое поле. Я видел, как команды записывали `DROP COLUMN` как откат для backfill, изменившего данные клиентов, а затем обнаруживали, что удаление новой колонки лишь уничтожило свидетельства, оставив старые значения ошибочными.

## Сначала расширяйте схему, потом удаляйте старое

Большинство изменений схемы, затрагивающих приложение, должны сохранять совместимость при работе разных версий. Во время развёртывания старые и новые процессы могут выполняться одновременно: workers завершаются медленно, пользователи долго держат запросы открытыми, а откат запускает старую сборку. Миграция, рассчитанная на одну мгновенно сменившуюся версию приложения, превращает нормальное развёртывание в сбой.

Предположим, нужно заменить `orders.status_text` на ограниченное значение `orders.status_code`. Небезопасный вариант добавляет новую колонку, переписывает все строки, меняет код приложения и удаляет старую колонку в одном релизе. В нём несколько точек отказа и нет спокойного места для остановки.

Используйте последовательность expand and contract:

1. Добавьте `status_code` как nullable и вспомогательные структуры, которые не ломают текущее приложение.
2. Выпустите код, который читает новое поле, если оно заполнено, и записывает оба поля либо безопасно выводит одно из другого.
3. Заполните существующие строки ограниченными пакетами, записывая прогресс вне временного контекста чата агента.
4. Проверьте, что все строки соответствуют новому инварианту и читатели используют новое поле.
5. В следующем релизе прекратите запись в старое поле, дождитесь согласованного срока хранения и удалите его.

Пакетный цикл важен. Один огромный `UPDATE` слишком долго удерживает ресурсы, создаёт всплеск write-ahead log, нагружает реплики и усложняет восстановление. Ограниченный запрос даёт оператору точку между коммитами, где можно проверить задержку репликации, частоту ошибок и нагрузку на базу.

Этот шаблон PostgreSQL обновляет выбранный пакет по primary key и возвращает изменённые строки:

```sql
WITH batch AS (
  SELECT id
  FROM public.orders
  WHERE status_code IS NULL
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 500
)
UPDATE public.orders AS o
SET status_code = CASE o.status_text
  WHEN 'new' THEN 10
  WHEN 'paid' THEN 20
  WHEN 'shipped' THEN 30
  ELSE NULL
END
FROM batch
WHERE o.id = batch.id
RETURNING o.id;
```

Runner повторяет операцию, пока возвращаются строки и преобразование даёт допустимые значения. `SKIP LOCKED` подходит для контролируемого worker, поскольку не заставляет ждать строки, удерживаемые другой транзакцией. Но это не доказывает, что обработаны все строки. Запрос проверки должен искать оставшиеся null и неожиданные исходные значения:

```sql
SELECT status_text, count(*)
FROM public.orders
WHERE status_code IS NULL
GROUP BY status_text
ORDER BY count(*) DESC;
```

Если запрос вернул неизвестное значение, остановитесь. Не позволяйте агенту решать, что неизвестное состояние продакшена можно превратить в значение enum по умолчанию только ради завершения задачи.

## Для выполнения нужны жёсткие условия остановки и ограниченные команды

Даже одобренная миграция может встретить состояние продакшена, отличное от того, которое изучал планировщик. Runner должен непосредственно перед выполнением провести preflight-проверки и остановиться при расхождении. Именно здесь процесс безопаснее runbook, который кто-то выполняет по памяти.

Используйте отдельный executor, принимающий только идентификатор артефакта и выбор target. Он должен отказывать при inline SQL, вставленном из терминала, отклонять артефакт с изменившейся checksum и печатать identity target до начала работы. Сначала executor может выполнить SQL для чтения, а затем потребовать отдельного одобрения изменяющей части.

Явно задавайте ограничения времени сессии. PostgreSQL описывает `lock_timeout` и `statement_timeout` как разные средства. Первый прерывает ожидание блокировки, второй прерывает слишком долго выполняющийся запрос. Для короткой операции с метаданными начало может выглядеть так:

```sql
SET lock_timeout = '5s';
SET statement_timeout = '60s';
SELECT current_database(), current_user, now();
```

Не переносите эти значения бездумно на построение индексов или backfill. Тайм-аут это бюджет, который должен быть обоснован планом. Если команда при ожидаемой нагрузке требует тридцать минут, тайм-аут запроса в шестьдесят секунд лишь создаст предсказуемый сбой. Для конкурентного создания индекса используйте путь runner, который не оборачивает команду в транзакцию:

```sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_status_code_idx
ON public.orders (status_code);
```

После этой команды проверьте индекс, а не считайте завершившийся процесс доказательством успеха. PostgreSQL хранит его корректность в метаданных каталога, а неудачное конкурентное построение может оставить некорректный индекс. Его нужно изучить и удалить перед повторной попыткой. Добавьте проверку в `verify.sql`, а где уместно, и план запроса, ориентированный на приложение.

Настоящее решение об остановке требует названных сигналов. Прерывайте выполнение, если preflight обнаружил неожиданную версию миграций, недостаточный запас места, активный блокирующий процесс вне разрешённого окна, неудачную проверку восстановления, ошибочную checksum или результат, не соответствующий предикату проверки. Оператор не должен договариваться с агентом, пока растёт ожидание блокировки.

## Одобрение должно связывать человека с одним живым запуском

Усталость от одобрений появляется, когда система просит подтверждать каждый SQL-вызов. Люди начинают подтверждать автоматически или отключают запросы. Одно подтверждение в начале определённого процесса агента полезно, если оно идентифицирует процесс, истекает при его завершении и не покрывает незаметно другую сессию позже.

Одобрение должно показывать достаточно контекста для отказа: подписанную identity процесса, выбранный target, идентификатор артефакта, предполагаемый класс действия и возможность записи через канал. Не показывайте необработанный пароль и не заставляйте агента работать с ним. Человек разрешает действие, а не раскрытие секрета.

Sallyport соответствует этой границе: он хранит API- и SSH-учётные данные в зашифрованном vault, позволяет агенту с поддержкой MCP запрашивать действия через `sp mcp` и возвращает результаты действий вместо секретов в открытом виде. Отдельное одобрение сессии и необязательное одобрение для каждого ключа позволяют потребовать решения человека для действия с базой продакшена, не помещая многоразовые учётные данные в контекст агента.

Ограничивайте одобрение этапом выполнения. Агент исследования может использовать путь только для чтения. Агент выполнения получает сессию, которая истекает вместе с процессом, и доступ только к одобренному маршруту действия. Мгновенный отзыв важен: оператору нужен способ остановить запуск после неожиданного результата preflight, а не только после завершения deployment script.

Не заменяйте ясный операционный дизайн языком политик, который команда не сможет объяснить под давлением. Надёжная граница проста: заблокированный доступ запрещает все действия, новый процесс выполнения требует авторизации, а особенно чувствительные учётные данные могут требовать нового решения при каждом использовании. Это легче проверять, чем набор непонятных правил, о которых никто не помнит.

## Аудит должен позволять восстановить изменение без секретов

После неудачной миграции возникают простые вопросы: кто её запустил, для какого target, какой точный артефакт выполнился, когда всё остановилось и затронуты ли данные? Обычная запись терминала редко отвечает на них полностью. В ней может не быть target, команда может исчезнуть после очистки shell history, а сами секреты могут попасть в лог.

Записывайте структурированные события для планирования, одобрения, preflight, выполнения, проверки, отзыва и ошибки. В каждое событие включайте идентификатор миграции и digest артефакта, чтобы связать ревью с живым запуском. Добавляйте identity базы, полученную preflight-проверкой, а не только имя, которое запросил вызывающий процесс.

Минимальная форма события выглядит так:

```json
{
  "event": "migration.verify",
  "migration_id": "2025-04-add-orders-status-index",
  "artifact_sha256": "recorded-digest",
  "target_identity": "production-orders",
  "result": "passed",
  "observed": "index valid; query returned expected columns"
}
```

Не записывайте параметры SQL, если в них могут быть персональные данные, токены или содержимое клиентов. Записывайте digest артефакта и безопасный идентификатор запроса. Система выполнения может хранить защищённую диагностику по установленной политике, но аудит-журнал должен оставаться полезным и не превращаться в ещё одно хранилище секретов.

Признак изменения повышает качество расследования. Запись, которую можно отредактировать после неудачного развёртывания, доказывает лишь, что у кого-то был доступ к ней. Если вы используете аудит-лог с цепочкой хешей, проверяйте её независимо во время разбора инцидента. `sp audit verify` в Sallyport проверяет зашифрованную аудит-цепочку офлайн без доступа к vault. Это правильное свойство для ревьюера, которому не нужны учётные данные продакшена.

## Проверка должна тестировать рабочую нагрузку, а не только DDL

Миграция успешна, когда нужная рабочая нагрузка работает правильно, а не когда база принимает команду. Новая колонка может существовать, хотя приложение не записывает её. Индекс может быть корректным, но целевой запрос не использует его из-за другого предиката или типа данных. Ограничение может пройти проверку, пока старый worker отправляет данные, нарушающие новый контракт приложения.

Пишите запросы для проверки до выполнения, пока ревьюеры ещё могут оспорить их предположения. Для миграции индекса проверяйте состояние каталога и изучайте запрос, ради которого он создавался. Для backfill считайте оставшиеся строки, группируйте неожиданные исходные значения и подтверждайте, что новый путь записи приложения создаёт нужное представление. Для ограничения проверяйте допустимые и недопустимые записи в одноразовой базе до включения правила в продакшене.

`EXPLAIN` в PostgreSQL полезен, но его легко применить неправильно. Решение планировщика зависит от статистики, значений параметров, распределения данных и конфигурации. `EXPLAIN (ANALYZE, BUFFERS)` выполняет запрос и измеряет его, поэтому не направляйте его бездумно на дорогой запрос продакшена. Используйте безопасный репрезентативный запрос и заранее определите приемлемый результат. Иначе любой план можно рационализировать усталому оператору.

Во время и после изменения следите за показателями приложения: частотой ошибок запросов, задержкой затронутого пути, давлением на подключения, состоянием репликации и сбоями workers. Агент может собрать и показать эти данные, но владелец релиза решает, соответствуют ли они условию завершения.

Не планируйте разрушительное сжатие схемы только потому, что развёртывание расширения прошло успешно. Дождитесь, когда наблюдаемость и история релизов покажут, что старые процессы приложения больше не зависят от старой схемы. Затем выполните удаление отдельной миграцией, прошедшей ревью. Это дешевле, чем выяснить во время отката, что старая сборка ожидает колонку, удалённую час назад.

## Первый запуск в продакшене должен быть намеренно скучным

Первая миграция в продакшене с участием агента должна добавлять малорисковое, наблюдаемое изменение, а не переделывать таблицу под нагрузкой. Выберите nullable-колонку, комментарий или другую операцию с хорошо понятным поведением. Используйте её, чтобы проверить границы: генерацию артефакта, выбор target, одобрение, подтверждение резервной копии, аудит-события, отказ preflight, выполнение и проверку.

Пусть упражнение докажет, что система умеет отказывать. Направьте executor на target с неправильным fingerprint схемы и убедитесь, что он остановится. Измените проверенный файл и убедитесь, что проверка digest отклонит его. Отзовите авторизацию во время безвредного тестового запуска и убедитесь, что последующие действия не выполняются. Такие проверки показывают, работают ли средства защиты в нужный момент, а не только хорошо ли они выглядят в проектном документе.

После этого расширяйте разрешённые классы миграций по одному. Команда, которая безопасно добавила колонку, ещё не доказала, что умеет заполнять большую таблицу, создавать конкурентный индекс или восстанавливаться после ошибочного преобразования данных. Для каждого класса нужен собственный наблюдаемый запуск и собственные критерии отказа.

Считайте полномочия в продакшене временным ресурсом для одной узкой задачи, который агент затем теряет. Такая привычка предотвращает больше ущерба, чем любая искусная инструкция в промпте.
