Миграции без остановки приложения¶
Миграция, которая применяется, пока приложение работает, не должна надолго блокировать большую таблицу и не должна ломать код, который ещё работает во время выкладки, — старую версию, которая читает колонки и таблицы такими, какими они были. hare проверяет миграции на то и другое (проверка), а его операции делают каждое рискованное изменение маленькими безопасными шагами — приём «расширение, затем сжатие» (expand-contract).
Проверка миграций¶
makemigrations проверяет миграции, которые записывает, и печатает рискованное — каждое с тем, как
сделать то же изменение безопасно:
WARNING: risky on a database in use if the tables are large - `hare checkmigrations` checks against the database:
app.0007_remove_book_pages: Remove field pages from Book [remove_field]
The column of Book.pages is dropped while the code still running reads it.
Safely: First remove the field from the models only - SeparateDatabaseAndState(state_operations=[RemoveField(model_name='Book', name='pages')]) - and drop the column with RunSQL in a later migration, once no running code reads it.
Базу она не читает, поэтому любая таблица, которая существует до миграции, может оказаться большой.
hare checkmigrations [APP ...] проверяет миграции, которые применил бы migrate, в его порядке, по
самой базе: таблица считается большой от migrations.safety.large_table_rows строк (по умолчанию 100
000). PostgreSQL берёт оценку планировщика (pg_class.reltuples), а для таблицы, которую ни разу не
анализировали, сам считает строки, но не дальше порога; SQLite считает их так же; ClickHouse берёт
счётчик, который ведут его движки таблиц (system.tables.total_rows), — представление, у которого
счётчика нет, считается большим. Команда завершается с кодом 1, пока есть риск без исключения, —
запускайте её в CI перед выкладкой.
large_table_rows — целое число строк; при 0 любая таблица большая.
Та же проверка из кода:
from hare.migrations.api import checkmigrations
risks = await checkmigrations(config=HARE_ORM, app_labels=["library"])
for risk in risks:
print(risk.code, risk.app_label, risk.migration_name, risk.operation, risk.message, risk.safe_alternative)
Каждый MigrationRisk называет правило (code, значение MigrationRiskCode), миграцию и её операцию
(как её пишет describe()), что пойдёт не так (message) и безопасный способ (safe_alternative);
exempted — True для риска, который миграция принимает.
- Таблица, которую создаёт та же миграция, новая и пустая, — правила о блокировках к ней не применяются.
- В
SeparateDatabaseAndStateоперации базы проверяются правилами о блокировках; правила о работающем коде (переименованные и удалённые поля и модели) к ним не применяются — такая операция и есть способ вручную держать код и базу согласованными. MigrationSafetyChecker(large_table_rows=...)(hare.migrations.safety) проверяет одну миграцию на заданном состоянии:await checker.check(migration, state, dialect=..., client=...)— безclientлюбая существующая таблица может быть большой.
Исключение риска¶
Риск, проверенный вручную, — прочитанный SQL, таблица, которая заведомо останется маленькой, —
принимается: его код перечисляется в safety_exemptions миграции. checkmigrations всё равно его
показывает, с пометкой (exempted), но из-за него не завершается с ошибкой:
from hare import migrations
from hare.migrations import operations as ops
from hare.migrations.safety import MigrationRiskCode
class Migration(migrations.Migration):
dependencies = [("library", "0006_loans")]
safety_exemptions = [MigrationRiskCode.RUN_SQL]
operations = [ops.RunSQL("UPDATE library_settings SET value = 'on' WHERE key = 'beta'")]
Элемент, который не является MigrationRiskCode, даёт ConfigurationError.
Правила¶
Правила живут в диалектах (Dialect.migration_safety_rules): у каждой базы есть общие, PostgreSQL
добавляет правила своих блокировок. «Большая» ниже — таблица, в которой может быть large_table_rows
строк и больше.
| Код | Базы | Что находит |
|---|---|---|
add_field_backfills_rows |
все | Поле NOT NULL только со значением по умолчанию на стороне Python в большой таблице |
add_field_without_default |
все | Поле NOT NULL вовсе без значения по умолчанию в таблице, где могут быть строки |
add_field_volatile_default |
PostgreSQL | Поле с изменчивым значением по умолчанию в базе в большой таблице |
alter_field_rewrites_table |
все | Изменение поля, которое переписывает большую таблицу |
add_index_without_concurrently |
PostgreSQL | Индекс строится на большой таблице, пока запись в неё ждёт |
add_check_constraint_validates_rows |
PostgreSQL | Ограничение CHECK на большой таблице без not_valid=True |
add_foreign_key_validates_rows |
PostgreSQL | Внешний ключ на большой таблице без not_valid=True |
add_unique_constraint_builds_index |
PostgreSQL | Уникальное ограничение, индекс которого строится на большой таблице |
set_not_null_scans_table |
PostgreSQL | NOT NULL ставится сканированием большой таблицы под блокировкой |
rename_field |
все | Поле переименовано вместе с колонкой |
remove_field |
все | Поле удалено вместе с колонкой |
rename_model |
все | Таблица переименована |
delete_model |
все | Модель удалена вместе с таблицей |
run_sql |
все | SQL как есть |
schema_change_with_run_python |
все | Код Python в транзакции, которая меняет схему большой таблицы |
add_field_backfills_rows¶
AddField поля NOT NULL, у которого значение по умолчанию есть только на стороне Python (default= или
auto_now), добавляет колонку с NULL, обновляет каждую существующую строку, затем делает колонку NOT
NULL — всё внутри миграции, держа таблицу. Безопасно: добавьте поле с null=True, заполните его
BackfillColumn в миграции с atomic = False, затем
сделайте NOT NULL через AlterColumnNotNullSafe (приём 1) — или дайте
постоянный db_default: база запомнит его один раз, не трогая строки.
add_field_without_default¶
AddField поля NOT NULL без default= (или auto_now) и без db_default: у базы нет значения для
строк, которые уже есть в таблице, и миграция падает на любой непустой таблице. Риск показывается для
каждой таблицы, которую миграция не создаёт, — в makemigrations и в checkmigrations без
подключения, а при чтении базы — для таблицы со строками. Безопасно: дайте полю default= или
db_default или добавьте его с null=True, заполните, затем сделайте NOT NULL
(приём 1).
add_field_volatile_default¶
Значение по умолчанию в базе, которое вызывает изменчивую функцию — random(), gen_random_uuid(),
uuidv7(), clock_timestamp(), nextval()..., — вычисляется для каждой существующей строки: PostgreSQL
переписывает таблицу. Постоянное значение или стабильная функция (Now()) запоминаются один раз.
Безопасно: добавьте поле с null=True без db_default, задайте db_default через AlterField (его
получат новые строки), заполните существующие строки BackfillColumn, затем AlterColumnNotNullSafe.
alter_field_rewrites_table¶
Изменение поля, которое база делает перезаписью таблицы; ни читать, ни писать её в это время нельзя.
Какое именно — решает редактор схемы диалекта (rewrites_table_on_alter()): PostgreSQL переписывает
таблицу при смене типа колонки, кроме типа, который вмещает каждое старое значение как есть, — более
длинный или неограниченный varchar, varchar в text и обратно в неограниченный, numeric с большим
числом цифр и тем же масштабом, cidr в inet; SQLite пересобирает таблицу при любом изменении, кроме
простого переименования и такого, после которого колонка объявлена как прежде (настройка на стороне
Python вроде sensitive, собственный индекс поля); ClickHouse переписывает при любой смене типа колонки.
Безопасно: добавьте поле нового вида, заполните его партиями, переведите на него код и удалите старое
(приём 2).
add_index_without_concurrently¶
AddIndex без concurrently=True, а также индекс поля, добавленного (или изменённого так, чтобы он
появился) с db_index=True — у внешнего ключа он есть по умолчанию, — строятся, пока запись в таблицу
ждёт. Безопасно: AddIndex(..., concurrently=True) в миграции с atomic = False (приём
3); для поля — объявите его с db_index=False и добавьте его индекс в Meta.indexes
этим способом.
add_check_constraint_validates_rows¶
AddConstraint с CheckConstraint проверяет каждую строку, пока таблицу нельзя ни читать, ни писать.
Безопасно: AddConstraint(..., not_valid=True) — проверяются только новые и изменённые строки — и
ValidateConstraint в следующей миграции; запись он не блокирует (приём 4).
add_foreign_key_validates_rows¶
AddField поля ForeignKeyField/OneToOneField с ограничением в базе проверяет каждую строку, пока
запись в обе таблицы ждёт; так же — AlterField, который даёт связи ограничение или другую цель.
Безопасно: AddField(..., not_valid=True) и позже ValidateConstraint с именем внешнего ключа
(приём 4); AlterField — внутри SeparateDatabaseAndState, где ограничение
добавляется NOT VALID через RunSQL.
add_unique_constraint_builds_index¶
AddConstraint с UniqueConstraint, а также поле, добавленное или изменённое с unique=True, строят
уникальный индекс ограничения, пока запись в таблицу ждёт. Безопасно: сначала постройте индекс через
AddIndex(..., unique=True, concurrently=True), затем пусть ограничение заберёт его себе:
AddConstraint(..., using_index=...) (приём 4). Уникальное ограничение с
condition забрать индекс не может — объявите его как уникальный PartialIndex.
set_not_null_scans_table¶
AlterField с null=True на null=False сканирует таблицу, пока её нельзя ни читать, ни писать; так же
— AlterColumnNotNullSafe внутри транзакции. Безопасно: AlterColumnNotNullSafe в миграции с
atomic = False (ниже).
rename_field и rename_model¶
Поле, переименованное вместе с колонкой (RenameField), модель, переименованная вместе с таблицей
(RenameModel модели без Meta.table), или изменённый Meta.table (AlterModelTable): код, который
ещё работает, обращается к старому имени и падает, пока его не заменят. Переименование, которое сохраняет
колонку или таблицу, в базе ничего не меняет и не показывается. Безопасно: сохраните имя в базе —
source_field="<old column>" у поля, Meta.table = "<old table>" у модели — или перейдите на новую
колонку (приём 2).
remove_field и delete_model¶
RemoveField удаляет колонку, DeleteModel — таблицу, пока работающий код их ещё читает. Безопасно:
сначала уберите их только из моделей — SeparateDatabaseAndState(state_operations=[RemoveField(...)]),
DeleteModel(..., state_only=True), — а колонку или таблицу удалите через RunSQL в следующей миграции,
когда её уже не читает ни один работающий код (приём 5).
run_sql¶
RunSQL: проверка не может сказать, какие таблицы и как надолго блокирует этот SQL.
Безопасно: прочитайте команды, затем исключите код в миграции.
schema_change_with_run_python¶
RunPython в атомарной миграции, которая также меняет схему большой таблицы, на базе, где изменения
схемы транзакционны: блокировки, взятые изменениями схемы, держатся до конца транзакции — всё время, пока
работает код. Безопасно: вынесите RunPython в отдельную миграцию или поставьте миграции
atomic = False.
BackfillColumn и AlterColumnNotNullSafe¶
class BackfillColumn:
def __init__(
self,
model_name: str,
field_name: str,
value: Any | Callable[[], Any],
*,
batch_size: int = 1000,
) -> None: ...
class AlterColumnNotNullSafe:
def __init__(self, model_name: str, field_name: str) -> None: ...
BackfillColumn выполняет цикл ограниченных UPDATE (не больше batch_size строк за команду, пока
строки не закончатся) вместо одного огромного UPDATE по всей таблице. value — значение или функция
без аргументов; функция вычисляется один раз (а не для каждой строки), поэтому она нужна для
«значения этого запуска миграции» (например, времени запуска), а не для значения, своего у каждой строки,
— ссылаться на другую колонку она не может. Операция меняет только данные, поэтому state_forward()
ничего не делает, и откатить её по смыслу нельзя (перезаписанные NULL не восстановить) — migrate к
более ранней миграции отказывается её откатывать. field_name может называть внешний ключ именем связи
("author") или полем-колонкой ("author_id"); тогда value — первичный ключ связанной строки
(принимается и объект модели). Имя без одной колонки в базе даёт ConfigurationError. Пакет выбирает
строки по первичному ключу — по всем колонкам составного, — а у модели без первичного ключа по
собственному имени строки в базе (ctid в PostgreSQL, rowid в SQLite); база без такого имени даёт
ConfigurationError для модели без первичного ключа. В миграции, которая идёт вне транзакции, оба
отказа случаются до того, как выполнится любая из её операций.
AlterColumnNotNullSafe делает колонку NOT NULL, проверив, что ни одна строка не хранит там NULL:
такая строка даёт hare.exceptions.ConfigurationError с именами модели и поля и советом использовать
BackfillColumn — вместо ошибки нарушения NOT NULL из базы; в миграции с atomic = False она становится
причиной (__cause__) PartiallyAppliedMigrationError, которую даёт ошибка такой миграции после первой
выполненной команды, а при отказе до первой команды выходит как есть. Как это делается:
- PostgreSQL, миграция с
atomic = False— таблица никогда не сканируется под блокировкой:CHECK (column IS NOT NULL)добавляетсяNOT VALID(без сканирования), проверяется черезVALIDATE CONSTRAINT(сканирование, которое не блокирует запись), колонке ставится NOT NULL — PostgreSQL берёт его из проверенного CHECK, а не из строк, — и CHECK удаляется. Если проверка нашлаNULL, CHECK удаляется до ошибки. - PostgreSQL, атомарная миграция —
LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODEдо конца транзакции, проверка наNULL, затемALTER COLUMN ... SET NOT NULL: новыйNULLмежду ними не проскочит, но запись ждёт, пока таблица сканируется (рискset_not_null_scans_table). - SQLite — проверка на
NULL, затем таблица пересобирается с колонкой NOT NULL. - ClickHouse — проверка на
NULL, затем колонка меняется так, как её меняетAlterField.
Приём 1: обязательная колонка в большой таблице¶
Если разбить это на три отдельные миграции, ни одна из них не будет блокировать таблицу всё время массового заполнения:
# 0004_add_status_nullable.py
class Migration(Migration):
operations = [
AddField("Loan", "status", CharField(max_length=20, null=True)),
]
# 0005_backfill_status.py
class Migration(Migration):
dependencies = [("models", "0004_add_status_nullable")]
atomic = False
operations = [
BackfillColumn("Loan", "status", value="pending", batch_size=5000),
]
# 0006_status_not_null.py
class Migration(Migration):
dependencies = [("models", "0005_backfill_status")]
atomic = False
operations = [
AlterColumnNotNullSafe("Loan", "status"),
]
Приём 2: переименование колонки через запись в обе¶
Операции RenameColumnSafe нет — настоящее переименование без остановки приложения — это рецепт из 4
шагов из существующих операций (AddField/RemoveField) и BackfillColumn, с периодом записи в обе
колонки в коде приложения между ними, который к hare не относится:
- Расширение — добавьте новую колонку; старый код чтения и записи это не затрагивает:
- Заполнение — заполните новую колонку у существующих строк.
BackfillColumnподходит, если все строки получают одно и то же значение (общее значение по умолчанию или заглушка); если новая колонка должна получить собственное значение каждой строки из старой, используйте миграцию данныхRunPythonс пакетнымUPDATE ... SET reader_email = email WHERE reader_email IS NULL—BackfillColumnзаписывает одно значение на всю партию и не может ссылаться на другую колонку строки.
async def backfill_reader_email(apps, schema_editor):
while True:
rowcount, _ = await schema_editor.client.execute(
'UPDATE "loan" SET "reader_email" = "email" '
'WHERE "id" IN (SELECT "id" FROM "loan" WHERE "reader_email" IS NULL LIMIT 5000)'
)
if not rowcount:
break
operations = [RunPython(backfill_reader_email, RunPython.noop)]
- Запись в обе — выпустите код приложения, который какое-то время пишет в обе колонки, чтобы и
старый, и новый код чтения видели актуальные данные (переопределённый
Model.save()или то же самое везде, где пишутся строки):
class Loan(Model):
email = fields.CharField(max_length=255)
reader_email = fields.CharField(max_length=255, null=True)
async def save(self, *args, **kwargs):
self.reader_email = self.email
await super().save(*args, **kwargs)
- Сжатие — когда весь код чтения и записи обновлён, а все строки заполнены, удалите старую колонку так, как это делает приём 5.
Каждый шаг выпускается и какое-то время работает в продакшене, прежде чем выполняется следующий, — именно это и даёт работу без остановки, а не какой-то механизм внутри hare.
Приём 3: добавление индекса¶
CREATE INDEX CONCURRENTLY строит индекс, пока в таблицу пишут; внутри транзакции он выполняться не
может, поэтому миграция не атомарная:
class Migration(Migration):
dependencies = [("models", "0006_status_not_null")]
atomic = False
operations = [
AddIndex("Loan", Index(fields=("status",), name="loan_status"), concurrently=True),
]
Неудачная сборка — дубликат под уникальным индексом, отменённый запрос — оставляет индекс в базе
INVALID: он есть, но ни один запрос его не использует и он ничего не обеспечивает. hare drift
сообщает, что он отличается от объявленного, а inspectdb помечает его # TODO; повторный AddIndex
миграции падает на его имени. Сначала удалите его (RunSQL("DROP INDEX CONCURRENTLY IF EXISTS
loan_status") в неатомарной миграции или вручную), затем мигрируйте снова.
Приём 4: добавление ограничения¶
Ограничение CHECK или внешний ключ добавляются без проверки — проверяются только новые и изменённые
строки, ничего не сканируется, — а существующие строки проверяет ValidateConstraint в следующей
миграции, с блокировкой, которая не мешает записи:
# 0008_constraints.py
operations = [
AddConstraint("Loan", CheckConstraint(check=Q(fee__gte=0), name="loan_fee_positive"), not_valid=True),
AddField("Loan", "branch", ForeignKeyField("models.Branch", null=True, db_index=False), not_valid=True),
]
# 0009_validate_constraints.py
operations = [
ValidateConstraint("Loan", "loan_fee_positive"),
ValidateConstraint("Loan", "fk_loan_branch__4b2b797a1ec4"), # имя внешнего ключа, как его показывает база
]
Уникальное ограничение забирает уникальный индекс, построенный без блокировки записи, —
UNIQUE USING INDEX переименовывает индекс в имя ограничения и ничего заново не проверяет:
class Migration(Migration):
atomic = False
operations = [
AddIndex("Loan", Index(fields=("number",), name="loan_number_unique", unique=True), concurrently=True),
AddConstraint(
"Loan", UniqueConstraint(fields=("number",), name="loan_number_unique"), using_index="loan_number_unique"
),
]
Модель объявляет только ограничение: using_index убирает индекс из Meta.indexes модели в состоянии
миграций.
Приём 5: удаление поля или модели¶
Сначала поле уходит из моделей; колонка удаляется, когда её уже не читает ни один работающий код:
# 0010_forget_legacy_code.py — выкладывается вместе с кодом, в котором поля уже нет
operations = [
SeparateDatabaseAndState(state_operations=[RemoveField(model_name="Loan", name="legacy_code")]),
]
# 0011_drop_legacy_code.py — после выкладки
safety_exemptions = [MigrationRiskCode.RUN_SQL]
operations = [
RunSQL('ALTER TABLE "loan" DROP COLUMN "legacy_code"', reverse_sql=RunSQL.noop),
]
Модель — так же: сначала DeleteModel("Loan", state_only=True), затем DROP TABLE через RunSQL. В
SQLite удалите колонку раньше, чем другое изменение этой таблицы её пересоберёт, — пересборка сохраняет
только колонки, которые знает состояние миграций (см. RemoveField).