atama plus techblog
🐈

カラム削除時にDjangoのMigrationロールバックでハマった話

2024/09/26に公開

はじめに

Django Migrationのロールバック時、NotNull制約によるIntegrityErrorにハマったので備忘のため書き残します。

起きたこと

既に参照されなくなっているプロパティをモデルから削除する実装時に、migration rollbackの確認したところエラーになることがわかりました。
具体的なエラーとしては以下です。
django.db.utils.IntegrityError: column "verifier_version" of relation "Survey" contains null values

モデルは以下のように定義されていました。

    class VerifierVersion(Enum):
        Version1 = "v1"
        Version2 = "v2"
        Version3 = "v3"

        @classmethod
        def choices(cls):
            return [(m.value, m.name) for m in cls]

    class Survey(models.Model):
        ...
        verifier_version = models.CharField(max_length=100, choices=VerifierVersion.choices())

エラーになったmigrationは以下です。

    from django.db import migrations


    class Migration(migrations.Migration):

        dependencies = [
            ("hoge_app", "0023_hoge"),
        ]

        operations = [
            migrations.RemoveField(
                model_name="survey",
                name="verifier_version",
            ),
        ]

課題解決のプロセス

どのような問題か

migrationのrollback時に、デフォルトなしかつ、NotNull制約ありのカラムを追加しようとしてエラーが発生しています。

解決方法

rollback時に追加したカラムに対して値を埋めることで対処しました。

migrations.RunPythonではforward時に実行するコードとreverse時に実行されるコードをそれぞれ指定できます。
reverse時、作成したカラムに値を入れる関数を実行することで、エラーを回避できます。

  from django.db import migrations, models


  def set_default_verifier_version(apps, schema_editor):
      OuterLinkSurvey = apps.get_model("hoge_app", "Survey")
      OuterLinkSurvey.objects.filter(verifier_version__isnull=True).update(verifier_version="v1")


  class Migration(migrations.Migration):
      dependencies = [
          ("hoge_app", "0023_hoge"),
      ]

      operations = [
          migrations.AlterField(
              model_name="survey",
              name="verifier_version",
              field=models.CharField(max_length=100, null=True, blank=True),
          ),
          migrations.RunPython(migrations.RunPython.noop, set_default_verifier_version),
          migrations.RemoveField(
              model_name="survey",
              name="verifier_version",
          ),
      ]

解決のために調べたこと

choiceが指定されたカラムはどのような定義なのか

Databaseの定義には影響しない、モデルのバリデーションのための定義です。
公式ドキュメントには記載がなかったのですが、ソースコードにはコメントが入っていました。

    # Attributes that don't affect a column definition.
    # These attributes are ignored when altering the field.
    non_db_attrs = (
        "blank",
        "choices",
        "db_column",
        "editable",
        "error_messages",
        "help_text",
        "limit_choices_to",
        # Database-level options are not supported, see #21961.
        "on_delete",
        "related_name",
        "related_query_name",
        "validators",
        "verbose_name",
    )

Operations全体を適用した後にvalidationがかかるのか

公式ドキュメントによれば、operationsを全て適用したスキーマへのSQL文を作成し、実際にデータに適用するのはまとめて行われるようです。

operations がポイントです。これは、宣言的な命令の集まりで、Django にどんなスキーマの変更が必要かを教えます。Django はそれらをスキャンして、全アプリへのスキーマの変更を完全に表現するデータ構造をメモリ上に作り上げ、これを利用して、Django スキーマを実際に変化させる SQL 文を生成します。

operationsのステップごとにdatabaseへ処理を適用するわけではないので、removeFieldステップ時点でNotNull制約をかけようとしても問題が起こらないという理解をしています。

Django固有の問題なのか

DjangoではremoveFieldにrollback時の挙動を制御するオプションを追加するissueが上がっていましたが、追加される予定はなさそうです。

他のmigrationツールではどうなのか、Prismaの場合を調べてみました。
Prismaではmigrationに失敗した場合の対応オプションが2つ紹介されています。

変更を元に戻す場合は、migration適用前後のdatabaseを比較して差分を埋めるためのSQLを作る方法が紹介されています。ロールバック時の挙動を制御するオプションはサポートされていないようです。
総じて、一度適用したmigrationをrollbackするオペレーションは、運用上頻繁にあるわけではありませんが、いざとなった時に使えるとリリース作業に安心感が感じられると思います。migarationツールを使う上で、リリース作業中の想定外の事象によりリリースを巻き戻し、後日問題を解決した上でリトライできる運用を確立しておくのは大切だと感じました。

終わりに

弊社では、開発工程で開発完了の定義(Done Of Definition)に沿っているか確認するチェックリストを運用しています。チェックリストの中に「migrationのrollbackがエラーなくできることを確認する」という項目があり、今回の事象に気づくことができました。
この項目は、過去のリリース作業においてリリースを中断しようとした際の学びが生かされているとのことでした。

データベースマイグレーションは、データ構造を更新するために不可欠で、システムの進化に伴うデータベースの整合性を保つ役割を果たします。
しかし、適切な計画とテストがなければ、運用中のデータ破損やサービス停止のリスクが高まり、特に複雑なシステムでは影響が大きくなります。

何もないように最善を尽くしつつ、仮に何かあった場合にもフォールバックが用意できるよう、気を配るのはとても大切だと感じました。

atama plus techblog
atama plus techblog

Discussion