Squashowanie i optymalizacja migracji w Django

Wraz z pojawieniem się wbudowanego w Django mechanizmu migracji programiści pracujący nad tym frameworkiem dali nam narzędzie do squashowania migracja - do łączenia szeregu migracji w jedną o zmniejszonej ilości operacji, zoptymalizowaną. Mając kilkanaście, czy kilkadziesiąt migracji w aplikacji możemy trochę poczekać aż zbuduje się baza do testów. Oszczędność czasu, jak i chęć pozbycia się historii kodu, który już nie istnieje (np. usunięte modele) to powody, dla których możemy migracje squashować. Operacja ta wymaga jednak poświęcenia nieco czasu.

Słów kilka o squashowaniu migracji

Żeby zesquashować migracje wystarczy odpalić management command squashmigrations podając nazwę aplikacji i numer migracji do której chcemy zesquashować poczynając od pierwszej. Tak wygenerowany plik będzie zawierał wszystkie zmiany z połączonych migracji. W miarę możliwości liczba kroków zostanie uproszczona, zmniejszona. W przypadku bardziej złożonych migracji będziemy musieli jeszcze edytować ten plik, by np. skopiować do niego kod funkcji używanych w operacjach RunPython.

Przy sporej liczbie zmian zapewne od razu nie uzyskamy idealnie zoptymalizowanego squasha. Żeby mieć szybką jedną migrację trzeba będzie np. odpowiednio poprzestawiać operacje tak by ponowny squash zoptymalizował pozostałe operacje - ale o tym za chwilę.

Procedura squashowania migracji w projekcie powinna wyglądać mniej więcej tak:

  • Wygenerowanie squasha za pomocą squashmigrations (i ew. edycja by działał). Komitujemy zmiany.
  • Gdy jest pewność że wszyscy zmigrowali się do stanu ze squasha usuwamy stare migracje, a w squashu usuwamy listę replaces. Trzeba będzie też zmienić nazwy migraci w zależnościach migracji z innych aplikacji o ile występują. Komitujemy zmiany.
  • Teraz możemy dodatkowo zająć się optymalizacją squasha o ile pozostają niezoptymalizowane operacje. Efekt naszych prac komitujemy i mamy załatwioną daną aplikację.

Wykonanie migrate powinno na istniejącej bazie oznaczyć squasha jako FAKED - bo to początkowa migracja. Niestety w większych projektach z zależnościami pomięcy aplikacjami (i migracjami) może skończyć się to tym że Django nie uzna squashy za migracje początkowe i będzie próbował je wykonać - co się nie uda bo tabele już istnieją. Na chwilę obecną jedyne rozwiązanie, choć niezbyt ładne to wykonanie migrate z flagą --fake o ile mamy pewność że nie ma innych migracji do wykonania niż squashe.

Migracje testować można odpalając jakiś nawet pusty test - tak by nałożone zostały na czystą bazę. Jeżeli nie chcemy wymuszać --fake przy deploju na produkcję to przed ich wrzutką warto sprawdzić je na istniejącej bazie danych.

System squashowania i migracji jest dość młody i pojawiają się błędy, lub braki. Te należy zgłaszać na tracu Django tak by developerzy mogli się nimi zająć (ja już kilka zgłosiłem siedząc nad squashami w Buku, czy Social WiFi).

Squashowanie i optymalizacje

Prosty optymalny przykład

Zacznijmy od idealnego, książkowego przypadku. Mamy aplikację bez powiązań z innymi i zestaw migracji, które coś robią na prostym modelu. Mamy trzy migracje dla trzech następujących stanów modelu:

class News(models.Model):
    title = models.CharField(max_length=300)

class News(models.Model):
    title = models.CharField(max_length=300)
    text = models.TextField()

class News(models.Model):
    title = models.CharField(max_length=300)
    text = models.TextField()
    published_date = models.DateTimeField(auto_now_add=True)

W pierwszej migracji tworzymy model, w drugiej dodamy pole text, a w trzeciej pole published date. Zesquashowanie tych trzech migracji da idealny wynik - jedną operację CreateModel:

class Migration(migrations.Migration):

    replaces = [('vanilla', '0001_initial'), ('vanilla', '0002_news_text'), ('vanilla', '0003_news_published_date')]

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='News',
            fields=[
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                ('title', models.CharField(max_length=300)),
                ('text', models.TextField(default='')),
                ('published_date', models.DateTimeField(auto_now_add=True, default=datetime.datetime(2015, 5, 24, 17, 26, 25, 326059, tzinfo=utc))),
            ],
        ),
    ]

Mając jakiś test można przetestować migrację na czystej bazie (vanilla to nazwa mojej testowej aplikacji, -v=2 potrzebne by widzieć listę wykonanych migracji):

python manage.py test vanilla -v=2

Zgodnie z dokumentacją możemy teraz usunąć stare migracje. Niestety w przypadku Djangio 1.8.2 usunięcie listy replaces psuje migracje dla istniejącej bazy danych (możliwe że jest to bug, wyjaśnię to zaraz...).

Przestawianie kolejności operacji

To teraz przykład, gdzie squash nie będzie idealny i trzeba będzie go dodatkowo optymalizować. Operacje takie jak RunPython, RunSQL, czy np. modyfikacja indeksów przerywają poszukiwanie optymalizatora i nie uwzględni on operacji znajdujących się za tymi operacjami. Jeżeli dalej mamy jakieś inne niepowiązane z nimi operacje (np. dodanie pola) to możemy przenieść je w kolejności nad te blokujące i zesquashować squasha żeby dostać zmniejszoną liczbę operacji (ja kopiuję listę operacji i usuwam podwójnego squasha by nie generować kolejnych plików).

Załużmy że rozbudowujemy taki mode:

class Person(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)

    class Meta:
        unique_together = ('first_name', 'last_name')


class Person(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    age = models.IntegerField(default=0)
    gender = models.CharField(choices=(('m', 'm'), ('f', 'f')), default='m', max_length=1)

    class Meta:
        unique_together = ('first_name', 'last_name')

W efekcie dostaniemy dwie migracje. Squash da taki zestaw operacji:

    operations = [
        migrations.CreateModel(
            name='Person',
            fields=[
                ('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
                ('first_name', models.CharField(max_length=100)),
                ('last_name', models.CharField(max_length=100)),
            ],
        ),
        migrations.AlterUniqueTogether(
            name='person',
            unique_together=set([('first_name', 'last_name')]),
        ),
        migrations.AddField(
            model_name='person',
            name='age',
            field=models.IntegerField(default=0),
        ),
        migrations.AddField(
            model_name='person',
            name='gender',
            field=models.CharField(choices=[('m', 'm'), ('f', 'f')], max_length=1, default='m'),
        ),
    ]

Jak widać operacje dodania pól nie zostały dodane do CreateModel. Być może optymalizator w przyszłości będzie mógł sobie radzić z takimi przypadkami, lecz na razie musimy zrobić to ręcznie. Przenosimy operacje AddField i generujemy nowego squasha. W konsoli powinniśmy zobaczyć informację o sukcesie optymalizatora:

Optimizing...
  Optimized from 4 operations to 2 operations.

Teraz możemy skopiować listę operacji i usunąć ten plik. W efekcie dostajemy ładny squash bez zbędnych operacji:

    operations = [
        migrations.CreateModel(
            name='Person',
            fields=[
                ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
                ('first_name', models.CharField(max_length=100)),
                ('last_name', models.CharField(max_length=100)),
                ('age', models.IntegerField(default=0)),
                ('gender', models.CharField(default='m', choices=[('m', 'm'), ('f', 'f')], max_length=1)),
            ],
        ),
        migrations.AlterUniqueTogether(
            name='person',
            unique_together=set([('first_name', 'last_name')]),
        ),
    ]

Podobnie może być w przypadku aplikacji z wieloma modelami. Wtedy trzeba przenosić operacje dotyczące danego model pod jego CreateModel. W przypadku relacji musi być zachowana kolejność. Czasami także CreateModel może być za nisko - np. gdy jest to model, do którego relację dostaje już istniejący. Wtedy CreateModel przenosimy wyżej, a operację dodająca pole relacji pod jej model. Dzięki temu pole relacji trafi do CreateModel.

Usuwanie operacji

Kolejny przypadek - tworzymy model Person, w drugiej migracji NewPerson, w trzeciej używamy RunPython (załużmy że do migracji danych), a w czwartej usuwamy stary model Person. Squash migracji wyświetli nam komunikat:

Manual porting required Your migrations contained functions that must be manually copied over, as we could not safely copy their implementation. See the comment at the top of the squashed migration for details.

Do squasha musimy przekopiować funkcję użytą w RunPython i poprawić jej użycie (ścieżkę wywołania). Oryginalny squash wygląda bowiem tak:

operations = [
        migrations.CreateModel(
            name='Person',
            fields=[
                ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)),
                ('first_name', models.CharField(max_length=100)),
                ('last_name', models.CharField(max_length=100)),
            ],
        ),
        migrations.AlterUniqueTogether(
            name='person',
            unique_together=set([('first_name', 'last_name')]),
        ),
        migrations.CreateModel(
            name='NewPerson',
            fields=[
                ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)),
                ('first_name', models.CharField(max_length=100)),
                ('last_name', models.CharField(max_length=100)),
            ],
        ),
        migrations.AlterUniqueTogether(
            name='newperson',
            unique_together=set([('first_name', 'last_name')]),
        ),
        migrations.RunPython(
            code=removing.migrations.0003_auto_20150524_1933.foo,
        ),
        migrations.DeleteModel(
            name='Person',
        ),
    ]

Migracje danych można usunąć w squashu - jak już zostały wykonane to nie są nam więcej potrzebne. Następnie możemy ręcznie wykasować wpisy dotyczące skasowanego modelu, lub przenieść kasowanie pod AlterUniqueTogether i wygenerować nowy squash żeby dostać zoptymalizowaną listę operacji.

Czasami przy wielu migracjach, zależnych aplikacjach usunięcie modelu może dać squasha, który nie będzie działał bo coś będzie chciało operować na usuniętym modelu (np. gdy do usuwanego modelu była relacja). Wtedy trzeba ręcznie usunąć operacje dotyczące tego modelu. Można też spróbować odtworzyć sytuację na prostym przykładzie i zgłosić na tracu Django. Squash powinien działać, a popsuty squash to bug.

Na zakończenie

Łączenie migracji w Django to nowy mechanizm, który jest raczej nieodzowny. Za czasów South migracje w testach można było pominąć. W przypadku migracji Django już tak nie jest. Twórcy są zdania iż należy testować na bazie w 100% odpowiadającej produkcyjnej. Niestety liczne migracje, nawet z bazą na dysku SSD będą zajmować trochę czasu, albo też nazbierają historii, której byśmy chcieli się już pozbyć bo np. jest niekompatybilna w jakiś sposób z nowszymi wersjami lub kodem.

Używanie najnowszej wersji Django powinno zapewnić najmniej bolesne squashowanie migracji. Są jeszcze czasami jakieś problemy, ale miejmy nadzieję że zostaną one wypolerowane tak by np. Django 2.0 mogło błyszczeć ;)

RkBlog

Django, 24 May 2015

Comment article
Comment article RkBlog main page Search RSS Contact