Fixed #25833 -- Added support for non-atomic migrations.

Added the Migration.atomic attribute which can be set to False
for non-atomic migrations.
This commit is contained in:
Pankrat 2016-01-30 21:46:28 +01:00 committed by Tim Graham
parent 0edb8a146f
commit f91a04621e
14 changed files with 181 additions and 16 deletions

View File

@ -51,6 +51,9 @@ class Command(BaseCommand):
migration_name, app_label)) migration_name, app_label))
targets = [(app_label, migration.name)] targets = [(app_label, migration.name)]
# Show begin/end around output only for atomic migrations
self.output_transaction = migration.atomic
# Make a plan that represents just the requested migrations and show SQL # Make a plan that represents just the requested migrations and show SQL
# for it # for it
plan = [(executor.loader.graph.nodes[targets[0]], options['backwards'])] plan = [(executor.loader.graph.nodes[targets[0]], options['backwards'])]

View File

@ -69,17 +69,18 @@ class BaseDatabaseSchemaEditor(object):
sql_create_pk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)s)" sql_create_pk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)s)"
sql_delete_pk = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s" sql_delete_pk = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s"
def __init__(self, connection, collect_sql=False): def __init__(self, connection, collect_sql=False, atomic=True):
self.connection = connection self.connection = connection
self.collect_sql = collect_sql self.collect_sql = collect_sql
if self.collect_sql: if self.collect_sql:
self.collected_sql = [] self.collected_sql = []
self.atomic_migration = self.connection.features.can_rollback_ddl and atomic
# State-managing methods # State-managing methods
def __enter__(self): def __enter__(self):
self.deferred_sql = [] self.deferred_sql = []
if self.connection.features.can_rollback_ddl: if self.atomic_migration:
self.atomic = atomic(self.connection.alias) self.atomic = atomic(self.connection.alias)
self.atomic.__enter__() self.atomic.__enter__()
return self return self
@ -88,7 +89,7 @@ class BaseDatabaseSchemaEditor(object):
if exc_type is None: if exc_type is None:
for sql in self.deferred_sql: for sql in self.deferred_sql:
self.execute(sql) self.execute(sql)
if self.connection.features.can_rollback_ddl: if self.atomic_migration:
self.atomic.__exit__(exc_type, exc_value, traceback) self.atomic.__exit__(exc_type, exc_value, traceback)
# Core utility functions # Core utility functions

View File

@ -170,7 +170,7 @@ class MigrationExecutor(object):
statements = [] statements = []
state = None state = None
for migration, backwards in plan: for migration, backwards in plan:
with self.connection.schema_editor(collect_sql=True) as schema_editor: with self.connection.schema_editor(collect_sql=True, atomic=migration.atomic) as schema_editor:
if state is None: if state is None:
state = self.loader.project_state((migration.app_label, migration.name), at_end=False) state = self.loader.project_state((migration.app_label, migration.name), at_end=False)
if not backwards: if not backwards:
@ -194,7 +194,7 @@ class MigrationExecutor(object):
fake = True fake = True
if not fake: if not fake:
# Alright, do it normally # Alright, do it normally
with self.connection.schema_editor() as schema_editor: with self.connection.schema_editor(atomic=migration.atomic) as schema_editor:
state = migration.apply(state, schema_editor) state = migration.apply(state, schema_editor)
# For replacement migrations, record individual statuses # For replacement migrations, record individual statuses
if migration.replaces: if migration.replaces:
@ -214,7 +214,7 @@ class MigrationExecutor(object):
if self.progress_callback: if self.progress_callback:
self.progress_callback("unapply_start", migration, fake) self.progress_callback("unapply_start", migration, fake)
if not fake: if not fake:
with self.connection.schema_editor() as schema_editor: with self.connection.schema_editor(atomic=migration.atomic) as schema_editor:
state = migration.unapply(state, schema_editor) state = migration.unapply(state, schema_editor)
# For replacement migrations, record individual statuses # For replacement migrations, record individual statuses
if migration.replaces: if migration.replaces:

View File

@ -48,6 +48,10 @@ class Migration(object):
# introspection. If False, never perform introspection. # introspection. If False, never perform introspection.
initial = None initial = None
# Whether to wrap the whole migration in a transaction. Only has an effect
# on database backends which support transactional DDL.
atomic = True
def __init__(self, name, app_label): def __init__(self, name, app_label):
self.name = name self.name = name
self.app_label = app_label self.app_label = app_label
@ -114,8 +118,10 @@ class Migration(object):
old_state = project_state.clone() old_state = project_state.clone()
operation.state_forwards(self.app_label, project_state) operation.state_forwards(self.app_label, project_state)
# Run the operation # Run the operation
if not schema_editor.connection.features.can_rollback_ddl and operation.atomic: atomic_operation = operation.atomic or (self.atomic and operation.atomic is not False)
# We're forcing a transaction on a non-transactional-DDL backend if not schema_editor.atomic_migration and atomic_operation:
# Force a transaction on a non-transactional-DDL backend or an
# atomic operation inside a non-atomic migration.
with atomic(schema_editor.connection.alias): with atomic(schema_editor.connection.alias):
operation.database_forwards(self.app_label, schema_editor, old_state, project_state) operation.database_forwards(self.app_label, schema_editor, old_state, project_state)
else: else:

View File

@ -139,7 +139,7 @@ class RunPython(Operation):
reduces_to_sql = False reduces_to_sql = False
def __init__(self, code, reverse_code=None, atomic=True, hints=None, elidable=False): def __init__(self, code, reverse_code=None, atomic=None, hints=None, elidable=False):
self.atomic = atomic self.atomic = atomic
# Forwards code # Forwards code
if not callable(code): if not callable(code):
@ -161,7 +161,7 @@ class RunPython(Operation):
} }
if self.reverse_code is not None: if self.reverse_code is not None:
kwargs['reverse_code'] = self.reverse_code kwargs['reverse_code'] = self.reverse_code
if self.atomic is not True: if self.atomic is not None:
kwargs['atomic'] = self.atomic kwargs['atomic'] = self.atomic
if self.hints: if self.hints:
kwargs['hints'] = self.hints kwargs['hints'] = self.hints

View File

@ -184,6 +184,53 @@ the respective field according to your needs.
migration is running. Objects created after the ``AddField`` and before migration is running. Objects created after the ``AddField`` and before
``RunPython`` will have their original ``uuid``s overwritten. ``RunPython`` will have their original ``uuid``s overwritten.
.. _non-atomic-migrations:
Non-atomic migrations
~~~~~~~~~~~~~~~~~~~~~
.. versionadded:: 1.10
On databases that support DDL transactions (SQLite and PostgreSQL), migrations
will run inside a transaction by default. For use cases such as performing data
migrations on large tables, you may want to prevent a migration from running in
a transaction by setting the ``atomic`` attribute to ``False``::
from django.db import migrations
class Migration(migrations.Migration):
atomic = False
Within such a migration, all operations are run without a transaction. It's
possible to execute parts of the migration inside a transaction using
:func:`~django.db.transaction.atomic()` or by passing ``atomic=True`` to
``RunPython``.
Here's an example of a non-atomic data migration that updates a large table in
smaller batches::
import uuid
from django.db import migrations, transaction
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model('myapp', 'MyModel')
while MyModel.objects.filter(uuid__isnull=True).exists():
with transaction.atomic():
for row in MyModel.objects.filter(uuid__isnull=True)[:1000]:
row.uuid = uuid.uuid4()
row.save()
class Migration(migrations.Migration):
atomic = False
operations = [
migrations.RunPython(gen_uuid),
]
The ``atomic`` attribute doesn't have an effect on databases that don't support
DDL transactions (e.g. MySQL, Oracle).
Controlling the order of migrations Controlling the order of migrations
=================================== ===================================

View File

@ -269,7 +269,7 @@ be removed (elided) when :ref:`squashing migrations <migration-squashing>`.
``RunPython`` ``RunPython``
------------- -------------
.. class:: RunPython(code, reverse_code=None, atomic=True, hints=None, elidable=False) .. class:: RunPython(code, reverse_code=None, atomic=None, hints=None, elidable=False)
Runs custom Python code in a historical context. ``code`` (and ``reverse_code`` Runs custom Python code in a historical context. ``code`` (and ``reverse_code``
if supplied) should be callable objects that accept two arguments; the first is if supplied) should be callable objects that accept two arguments; the first is
@ -354,16 +354,19 @@ the ``schema_editor`` provided on these backends; in this case, pass
On databases that do support DDL transactions (SQLite and PostgreSQL), On databases that do support DDL transactions (SQLite and PostgreSQL),
``RunPython`` operations do not have any transactions automatically added ``RunPython`` operations do not have any transactions automatically added
besides the transactions created for each migration (the ``atomic`` parameter besides the transactions created for each migration. Thus, on PostgreSQL, for
has no effect on these databases). Thus, on PostgreSQL, for example, you should example, you should avoid combining schema changes and ``RunPython`` operations
avoid combining schema changes and ``RunPython`` operations in the same in the same migration or you may hit errors like ``OperationalError: cannot
migration or you may hit errors like ``OperationalError: cannot ALTER TABLE ALTER TABLE "mytable" because it has pending trigger events``.
"mytable" because it has pending trigger events``.
If you have a different database and aren't sure if it supports DDL If you have a different database and aren't sure if it supports DDL
transactions, check the ``django.db.connection.features.can_rollback_ddl`` transactions, check the ``django.db.connection.features.can_rollback_ddl``
attribute. attribute.
If the ``RunPython`` operation is part of a :ref:`non-atomic migration
<non-atomic-migrations>`, the operation will only be executed in a transaction
if ``atomic=True`` is passed to the ``RunPython`` operation.
.. warning:: .. warning::
``RunPython`` does not magically alter the connection of the models for you; ``RunPython`` does not magically alter the connection of the models for you;
@ -382,6 +385,11 @@ attribute.
The ``elidable`` argument was added. The ``elidable`` argument was added.
.. versionchanged:: 1.10
The ``atomic`` argument default was changed to ``None``, indicating that
the atomicity is controlled by the ``atomic`` attribute of the migration.
``SeparateDatabaseAndState`` ``SeparateDatabaseAndState``
---------------------------- ----------------------------

View File

@ -258,6 +258,9 @@ Migrations
:class:`~django.db.migrations.operations.RunPython` operations to allow them :class:`~django.db.migrations.operations.RunPython` operations to allow them
to be removed when squashing migrations. to be removed when squashing migrations.
* Added support for :ref:`non-atomic migrations <non-atomic-migrations>` by
setting the ``atomic`` attribute on a ``Migration``.
Models Models
~~~~~~ ~~~~~~

View File

@ -362,6 +362,19 @@ class MigrateTests(MigrationTestBase):
# Cleanup by unmigrating everything # Cleanup by unmigrating everything
call_command("migrate", "migrations", "zero", verbosity=0) call_command("migrate", "migrations", "zero", verbosity=0)
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_non_atomic"})
def test_sqlmigrate_for_non_atomic_migration(self):
"""
Transaction wrappers aren't shown for non-atomic migrations.
"""
out = six.StringIO()
call_command("sqlmigrate", "migrations", "0001", stdout=out)
output = out.getvalue().lower()
queries = [q.strip() for q in output.splitlines()]
if connection.ops.start_transaction_sql():
self.assertNotIn(connection.ops.start_transaction_sql().lower(), queries)
self.assertNotIn(connection.ops.end_transaction_sql().lower(), queries)
@override_settings( @override_settings(
INSTALLED_APPS=[ INSTALLED_APPS=[
"migrations.migrations_test_apps.migrated_app", "migrations.migrations_test_apps.migrated_app",

View File

@ -100,6 +100,33 @@ class ExecutorTests(MigrationTestBase):
self.assertTableNotExists("migrations_author") self.assertTableNotExists("migrations_author")
self.assertTableNotExists("migrations_book") self.assertTableNotExists("migrations_book")
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_non_atomic"})
def test_non_atomic_migration(self):
"""
Applying a non-atomic migration works as expected.
"""
executor = MigrationExecutor(connection)
with self.assertRaisesMessage(RuntimeError, "Abort migration"):
executor.migrate([("migrations", "0001_initial")])
self.assertTableExists("migrations_publisher")
migrations_apps = executor.loader.project_state(("migrations", "0001_initial")).apps
Publisher = migrations_apps.get_model("migrations", "Publisher")
self.assertTrue(Publisher.objects.exists())
self.assertTableNotExists("migrations_book")
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_atomic_operation"})
def test_atomic_operation_in_non_atomic_migration(self):
"""
An atomic operation is properly rolled back inside a non-atomic
migration.
"""
executor = MigrationExecutor(connection)
with self.assertRaisesMessage(RuntimeError, "Abort migration"):
executor.migrate([("migrations", "0001_initial")])
migrations_apps = executor.loader.project_state(("migrations", "0001_initial")).apps
Editor = migrations_apps.get_model("migrations", "Editor")
self.assertFalse(Editor.objects.exists())
@override_settings(MIGRATION_MODULES={ @override_settings(MIGRATION_MODULES={
"migrations": "migrations.test_migrations", "migrations": "migrations.test_migrations",
"migrations2": "migrations2.test_migrations_2", "migrations2": "migrations2.test_migrations_2",

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def raise_error(apps, schema_editor):
# Test atomic operation in non-atomic migration is wrapped in transaction
Editor = apps.get_model('migrations', 'Editor')
Editor.objects.create(name='Test Editor')
raise RuntimeError('Abort migration')
class Migration(migrations.Migration):
atomic = False
operations = [
migrations.CreateModel(
"Editor",
[
("name", models.CharField(primary_key=True, max_length=255)),
],
),
migrations.RunPython(raise_error, atomic=True),
]

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def raise_error(apps, schema_editor):
# Test operation in non-atomic migration is not wrapped in transaction
Publisher = apps.get_model('migrations', 'Publisher')
Publisher.objects.create(name='Test Publisher')
raise RuntimeError('Abort migration')
class Migration(migrations.Migration):
atomic = False
operations = [
migrations.CreateModel(
"Publisher",
[
("name", models.CharField(primary_key=True, max_length=255)),
],
),
migrations.RunPython(raise_error),
migrations.CreateModel(
"Book",
[
("title", models.CharField(primary_key=True, max_length=255)),
("publisher", models.ForeignKey("migrations.Publisher", models.SET_NULL, null=True)),
],
),
]