Skip to content

Commit 5aa5503

Browse files
committed
Fixed #24743, #24745 -- Optimized migration plan handling
The change partly goes back to the old behavior for forwards migrations which should reduce the amount of memory consumption (#24745). However, by the way the current state computation is done (there is no `state_backwards` on a migration class) this change cannot be applied to backwards migrations. Hence rolling back migrations still requires the precomputation and storage of the intermediate migration states. This improvement also implies that Django does not handle mixed migration plans anymore. Mixed plans consist of a list of migrations where some are being applied and others are being unapplied. Thanks Andrew Godwin, Josh Smeaton and Tim Graham for the review as well as everybody involved on the ticket that kept me looking into the issue.
1 parent 186eb21 commit 5aa5503

File tree

7 files changed

+184
-27
lines changed

7 files changed

+184
-27
lines changed

django/db/migrations/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,7 @@ def __repr__(self):
5858

5959
class MigrationSchemaMissing(DatabaseError):
6060
pass
61+
62+
63+
class InvalidMigrationPlan(ValueError):
64+
pass

django/db/migrations/executor.py

+77-27
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.apps.registry import apps as global_apps
44
from django.db import migrations
55

6+
from .exceptions import InvalidMigrationPlan
67
from .loader import MigrationLoader
78
from .recorder import MigrationRecorder
89
from .state import ProjectState
@@ -71,46 +72,95 @@ def migrate(self, targets, plan=None, fake=False, fake_initial=False):
7172
"""
7273
if plan is None:
7374
plan = self.migration_plan(targets)
74-
migrations_to_run = {m[0] for m in plan}
7575
# Create the forwards plan Django would follow on an empty database
7676
full_plan = self.migration_plan(self.loader.graph.leaf_nodes(), clean_start=True)
77-
# Holds all states right before a migration is applied
78-
# if the migration is being run.
77+
78+
all_forwards = all(not backwards for mig, backwards in plan)
79+
all_backwards = all(backwards for mig, backwards in plan)
80+
81+
if not plan:
82+
pass # Nothing to do for an empty plan
83+
elif all_forwards == all_backwards:
84+
# This should only happen if there's a mixed plan
85+
raise InvalidMigrationPlan(
86+
"Migration plans with both forwards and backwards migrations "
87+
"are not supported. Please split your migration process into "
88+
"separate plans of only forwards OR backwards migrations.",
89+
plan
90+
)
91+
elif all_forwards:
92+
self._migrate_all_forwards(plan, full_plan, fake=fake, fake_initial=fake_initial)
93+
else:
94+
# No need to check for `elif all_backwards` here, as that condition
95+
# would always evaluate to true.
96+
self._migrate_all_backwards(plan, full_plan, fake=fake)
97+
98+
self.check_replacements()
99+
100+
def _migrate_all_forwards(self, plan, full_plan, fake, fake_initial):
101+
"""
102+
Take a list of 2-tuples of the form (migration instance, False) and
103+
apply them in the order they occur in the full_plan.
104+
"""
105+
migrations_to_run = {m[0] for m in plan}
106+
state = ProjectState(real_apps=list(self.loader.unmigrated_apps))
107+
for migration, _ in full_plan:
108+
if not migrations_to_run:
109+
# We remove every migration that we applied from this set so
110+
# that we can bail out once the last migration has been applied
111+
# and don't always run until the very end of the migration
112+
# process.
113+
break
114+
if migration in migrations_to_run:
115+
if 'apps' not in state.__dict__:
116+
if self.progress_callback:
117+
self.progress_callback("render_start")
118+
state.apps # Render all -- performance critical
119+
if self.progress_callback:
120+
self.progress_callback("render_success")
121+
state = self.apply_migration(state, migration, fake=fake, fake_initial=fake_initial)
122+
migrations_to_run.remove(migration)
123+
else:
124+
migration.mutate_state(state, preserve=False)
125+
126+
def _migrate_all_backwards(self, plan, full_plan, fake):
127+
"""
128+
Take a list of 2-tuples of the form (migration instance, True) and
129+
unapply them in reverse order they occur in the full_plan.
130+
131+
Since unapplying a migration requires the project state prior to that
132+
migration, Django will compute the migration states before each of them
133+
in a first run over the plan and then unapply them in a second run over
134+
the plan.
135+
"""
136+
migrations_to_run = {m[0] for m in plan}
137+
# Holds all migration states prior to the migrations being unapplied
79138
states = {}
80139
state = ProjectState(real_apps=list(self.loader.unmigrated_apps))
81140
if self.progress_callback:
82141
self.progress_callback("render_start")
83-
# Phase 1 -- Store all project states of migrations right before they
84-
# are applied. The first migration that will be applied in phase 2 will
85-
# trigger the rendering of the initial project state. From this time on
86-
# models will be recursively reloaded as explained in
87-
# `django.db.migrations.state.get_related_models_recursive()`.
88142
for migration, _ in full_plan:
89143
if not migrations_to_run:
90-
# We remove every migration whose state was already computed
91-
# from the set below (`migrations_to_run.remove(migration)`).
92-
# If no states for migrations must be computed, we can exit
93-
# this loop. Migrations that occur after the latest migration
94-
# that is about to be applied would only trigger unneeded
95-
# mutate_state() calls.
144+
# We remove every migration that we applied from this set so
145+
# that we can bail out once the last migration has been applied
146+
# and don't always run until the very end of the migration
147+
# process.
96148
break
97-
do_run = migration in migrations_to_run
98-
if do_run:
149+
if migration in migrations_to_run:
99150
if 'apps' not in state.__dict__:
100-
state.apps # Render all real_apps -- performance critical
101-
states[migration] = state.clone()
151+
state.apps # Render all -- performance critical
152+
# The state before this migration
153+
states[migration] = state
154+
# The old state keeps as-is, we continue with the new state
155+
state = migration.mutate_state(state, preserve=True)
102156
migrations_to_run.remove(migration)
103-
# Only preserve the state if the migration is being run later
104-
state = migration.mutate_state(state, preserve=do_run)
157+
else:
158+
migration.mutate_state(state, preserve=False)
105159
if self.progress_callback:
106160
self.progress_callback("render_success")
107-
# Phase 2 -- Run the migrations
108-
for migration, backwards in plan:
109-
if not backwards:
110-
self.apply_migration(states[migration], migration, fake=fake, fake_initial=fake_initial)
111-
else:
112-
self.unapply_migration(states[migration], migration, fake=fake)
113-
self.check_replacements()
161+
162+
for migration, _ in plan:
163+
self.unapply_migration(states[migration], migration, fake=fake)
114164

115165
def collect_sql(self, plan):
116166
"""

docs/releases/1.9.txt

+19
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,21 @@ Migrations
458458
* When supplying ``None`` as a value in :setting:`MIGRATION_MODULES`, Django
459459
will consider the app an app without migrations.
460460

461+
* When applying migrations, the "Rendering model states" step that's displayed
462+
when running migrate with verbosity 2 or higher now computes only the states
463+
for the migrations that have already been applied. The model states for
464+
migrations being applied are generated on demand, drastically reducing the
465+
amount of required memory.
466+
467+
However, this improvement is not available when unapplying migrations and
468+
therefore still requires the precomputation and storage of the intermediate
469+
migration states.
470+
471+
This improvement also requires that Django no longer supports mixed migration
472+
plans. Mixed plans consist of a list of migrations where some are being
473+
applied and others are being unapplied. This was never officially supported
474+
and never had a public API that supports this behavior.
475+
461476
Models
462477
^^^^^^
463478

@@ -1094,6 +1109,10 @@ Miscellaneous
10941109
* The system checks for :class:`~django.contrib.admin.ModelAdmin` now check
10951110
instances rather than classes.
10961111

1112+
* The private API to apply mixed migration plans has been dropped for
1113+
performance reasons. Mixed plans consist of a list of migrations where some
1114+
are being applied and others are being unapplied.
1115+
10971116
.. _deprecated-features-1.9:
10981117

10991118
Features deprecated in 1.9

docs/spelling_wordlist

+1
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ postgresql
580580
pq
581581
pre
582582
precisions
583+
precomputation
583584
preconfigured
584585
prefetch
585586
prefetched

tests/migrations/test_executor.py

+59
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.apps.registry import apps as global_apps
22
from django.db import connection
3+
from django.db.migrations.exceptions import InvalidMigrationPlan
34
from django.db.migrations.executor import MigrationExecutor
45
from django.db.migrations.graph import MigrationGraph
56
from django.db.migrations.recorder import MigrationRecorder
@@ -145,6 +146,64 @@ def test_empty_plan(self):
145146
executor.recorder.record_unapplied("migrations", "0002_second")
146147
executor.recorder.record_unapplied("migrations", "0001_initial")
147148

149+
@override_settings(MIGRATION_MODULES={
150+
"migrations": "migrations.test_migrations",
151+
"migrations2": "migrations2.test_migrations_2_no_deps",
152+
})
153+
def test_mixed_plan_not_supported(self):
154+
"""
155+
Although the MigrationExecutor interfaces allows for mixed migration
156+
plans (combined forwards and backwards migrations) this is not
157+
supported.
158+
"""
159+
# Prepare for mixed plan
160+
executor = MigrationExecutor(connection)
161+
plan = executor.migration_plan([("migrations", "0002_second")])
162+
self.assertEqual(
163+
plan,
164+
[
165+
(executor.loader.graph.nodes["migrations", "0001_initial"], False),
166+
(executor.loader.graph.nodes["migrations", "0002_second"], False),
167+
],
168+
)
169+
executor.migrate(None, plan)
170+
# Rebuild the graph to reflect the new DB state
171+
executor.loader.build_graph()
172+
self.assertIn(('migrations', '0001_initial'), executor.loader.applied_migrations)
173+
self.assertIn(('migrations', '0002_second'), executor.loader.applied_migrations)
174+
self.assertNotIn(('migrations2', '0001_initial'), executor.loader.applied_migrations)
175+
176+
# Generate mixed plan
177+
plan = executor.migration_plan([
178+
("migrations", None),
179+
("migrations2", "0001_initial"),
180+
])
181+
msg = (
182+
'Migration plans with both forwards and backwards migrations are '
183+
'not supported. Please split your migration process into separate '
184+
'plans of only forwards OR backwards migrations.'
185+
)
186+
with self.assertRaisesMessage(InvalidMigrationPlan, msg) as cm:
187+
executor.migrate(None, plan)
188+
self.assertEqual(
189+
cm.exception.args[1],
190+
[
191+
(executor.loader.graph.nodes["migrations", "0002_second"], True),
192+
(executor.loader.graph.nodes["migrations", "0001_initial"], True),
193+
(executor.loader.graph.nodes["migrations2", "0001_initial"], False),
194+
],
195+
)
196+
# Rebuild the graph to reflect the new DB state
197+
executor.loader.build_graph()
198+
executor.migrate([
199+
("migrations", None),
200+
("migrations2", None),
201+
])
202+
# Are the tables gone?
203+
self.assertTableNotExists("migrations_author")
204+
self.assertTableNotExists("migrations_book")
205+
self.assertTableNotExists("migrations2_otherauthor")
206+
148207
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"})
149208
def test_soft_apply(self):
150209
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = []
10+
11+
operations = [
12+
13+
migrations.CreateModel(
14+
"OtherAuthor",
15+
[
16+
("id", models.AutoField(primary_key=True)),
17+
("name", models.CharField(max_length=255)),
18+
("slug", models.SlugField(null=True)),
19+
("age", models.IntegerField(default=0)),
20+
("silly_field", models.BooleanField(default=False)),
21+
],
22+
),
23+
24+
]

tests/migrations2/test_migrations_2_no_deps/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)