Implemented an 'atomic' decorator and context manager.

Currently it only works in autocommit mode.

Based on @xact by Christophe Pettus.
This commit is contained in:
Aymeric Augustin 2013-03-04 22:17:35 +01:00
parent 4b31a6a9e6
commit d7bc4fbc94
7 changed files with 430 additions and 23 deletions

View File

@ -434,6 +434,7 @@ answer newbie questions, and generally made Django that much better:
Andreas Pelme <andreas@pelme.se> Andreas Pelme <andreas@pelme.se>
permonik@mesias.brnonet.cz permonik@mesias.brnonet.cz
peter@mymart.com peter@mymart.com
Christophe Pettus <xof@thebuild.com>
pgross@thoughtworks.com pgross@thoughtworks.com
phaedo <http://phaedo.cx/> phaedo <http://phaedo.cx/>
phil@produxion.net phil@produxion.net

View File

@ -50,6 +50,12 @@ class BaseDatabaseWrapper(object):
# set somewhat aggressively, as the DBAPI doesn't make it easy to # set somewhat aggressively, as the DBAPI doesn't make it easy to
# deduce if the connection is in transaction or not. # deduce if the connection is in transaction or not.
self._dirty = False self._dirty = False
# Tracks if the connection is in a transaction managed by 'atomic'
self.in_atomic_block = False
# List of savepoints created by 'atomic'
self.savepoint_ids = []
# Hack to provide compatibility with legacy transaction management
self._atomic_forced_unmanaged = False
# Connection termination related attributes # Connection termination related attributes
self.close_at = None self.close_at = None
@ -148,7 +154,7 @@ class BaseDatabaseWrapper(object):
def commit(self): def commit(self):
""" """
Does the commit itself and resets the dirty flag. Commits a transaction and resets the dirty flag.
""" """
self.validate_thread_sharing() self.validate_thread_sharing()
self._commit() self._commit()
@ -156,7 +162,7 @@ class BaseDatabaseWrapper(object):
def rollback(self): def rollback(self):
""" """
Does the rollback itself and resets the dirty flag. Rolls back a transaction and resets the dirty flag.
""" """
self.validate_thread_sharing() self.validate_thread_sharing()
self._rollback() self._rollback()
@ -447,6 +453,12 @@ class BaseDatabaseWrapper(object):
if must_close: if must_close:
self.close() self.close()
def _start_transaction_under_autocommit(self):
"""
Only required when autocommits_when_autocommit_is_off = True.
"""
raise NotImplementedError
class BaseDatabaseFeatures(object): class BaseDatabaseFeatures(object):
allows_group_by_pk = False allows_group_by_pk = False
@ -549,6 +561,10 @@ class BaseDatabaseFeatures(object):
# Support for the DISTINCT ON clause # Support for the DISTINCT ON clause
can_distinct_on_fields = False can_distinct_on_fields = False
# Does the backend decide to commit before SAVEPOINT statements
# when autocommit is disabled? http://bugs.python.org/issue8145#msg109965
autocommits_when_autocommit_is_off = False
def __init__(self, connection): def __init__(self, connection):
self.connection = connection self.connection = connection
@ -931,6 +947,9 @@ class BaseDatabaseOperations(object):
return "BEGIN;" return "BEGIN;"
def end_transaction_sql(self, success=True): def end_transaction_sql(self, success=True):
"""
Returns the SQL statement required to end a transaction.
"""
if not success: if not success:
return "ROLLBACK;" return "ROLLBACK;"
return "COMMIT;" return "COMMIT;"

View File

@ -99,6 +99,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_mixed_date_datetime_comparisons = False supports_mixed_date_datetime_comparisons = False
has_bulk_insert = True has_bulk_insert = True
can_combine_inserts_with_and_without_auto_increment_pk = False can_combine_inserts_with_and_without_auto_increment_pk = False
autocommits_when_autocommit_is_off = True
@cached_property @cached_property
def uses_savepoints(self): def uses_savepoints(self):
@ -360,10 +361,12 @@ class DatabaseWrapper(BaseDatabaseWrapper):
BaseDatabaseWrapper.close(self) BaseDatabaseWrapper.close(self)
def _savepoint_allowed(self): def _savepoint_allowed(self):
# When 'isolation_level' is None, Django doesn't provide a way to # When 'isolation_level' is not None, sqlite3 commits before each
# create a transaction (yet) so savepoints can't be created. When it # savepoint; it's a bug. When it is None, savepoints don't make sense
# isn't, sqlite3 commits before each savepoint -- it's a bug. # because autocommit is enabled. The only exception is inside atomic
return False # blocks. To work around that bug, on SQLite, atomic starts a
# transaction explicitly rather than simply disable autocommit.
return self.in_atomic_block
def _set_autocommit(self, autocommit): def _set_autocommit(self, autocommit):
if autocommit: if autocommit:
@ -413,6 +416,14 @@ class DatabaseWrapper(BaseDatabaseWrapper):
def is_usable(self): def is_usable(self):
return True return True
def _start_transaction_under_autocommit(self):
"""
Start a transaction explicitly in autocommit mode.
Staying in autocommit mode works around a bug of sqlite3 that breaks
savepoints when autocommit is disabled.
"""
self.cursor().execute("BEGIN")
FORMAT_QMARK_REGEX = re.compile(r'(?<!%)%s') FORMAT_QMARK_REGEX = re.compile(r'(?<!%)%s')

View File

@ -16,7 +16,7 @@ import warnings
from functools import wraps from functools import wraps
from django.db import connections, DEFAULT_DB_ALIAS from django.db import connections, DatabaseError, DEFAULT_DB_ALIAS
class TransactionManagementError(Exception): class TransactionManagementError(Exception):
@ -134,13 +134,13 @@ def rollback_unless_managed(using=None):
def commit(using=None): def commit(using=None):
""" """
Does the commit itself and resets the dirty flag. Commits a transaction and resets the dirty flag.
""" """
get_connection(using).commit() get_connection(using).commit()
def rollback(using=None): def rollback(using=None):
""" """
This function does the rollback itself and resets the dirty flag. Rolls back a transaction and resets the dirty flag.
""" """
get_connection(using).rollback() get_connection(using).rollback()
@ -166,9 +166,151 @@ def savepoint_commit(sid, using=None):
""" """
get_connection(using).savepoint_commit(sid) get_connection(using).savepoint_commit(sid)
##############
# DECORATORS # #################################
############## # Decorators / context managers #
#################################
class Atomic(object):
"""
This class guarantees the atomic execution of a given block.
An instance can be used either as a decorator or as a context manager.
When it's used as a decorator, __call__ wraps the execution of the
decorated function in the instance itself, used as a context manager.
When it's used as a context manager, __enter__ creates a transaction or a
savepoint, depending on whether a transaction is already in progress, and
__exit__ commits the transaction or releases the savepoint on normal exit,
and rolls back the transaction or to the savepoint on exceptions.
A stack of savepoints identifiers is maintained as an attribute of the
connection. None denotes a plain transaction.
This allows reentrancy even if the same AtomicWrapper is reused. For
example, it's possible to define `oa = @atomic('other')` and use `@ao` or
`with oa:` multiple times.
Since database connections are thread-local, this is thread-safe.
"""
def __init__(self, using):
self.using = using
def _legacy_enter_transaction_management(self, connection):
if not connection.in_atomic_block:
if connection.transaction_state and connection.transaction_state[-1]:
connection._atomic_forced_unmanaged = True
connection.enter_transaction_management(managed=False)
else:
connection._atomic_forced_unmanaged = False
def _legacy_leave_transaction_management(self, connection):
if not connection.in_atomic_block and connection._atomic_forced_unmanaged:
connection.leave_transaction_management()
def __enter__(self):
connection = get_connection(self.using)
# Ensure we have a connection to the database before testing
# autocommit status.
connection.ensure_connection()
# Remove this when the legacy transaction management goes away.
self._legacy_enter_transaction_management(connection)
if not connection.in_atomic_block and not connection.autocommit:
raise TransactionManagementError(
"'atomic' cannot be used when autocommit is disabled.")
if connection.in_atomic_block:
# We're already in a transaction; create a savepoint.
sid = connection.savepoint()
connection.savepoint_ids.append(sid)
else:
# We aren't in a transaction yet; create one.
# The usual way to start a transaction is to turn autocommit off.
# However, some database adapters (namely sqlite3) don't handle
# transactions and savepoints properly when autocommit is off.
# In such cases, start an explicit transaction instead, which has
# the side-effect of disabling autocommit.
if connection.features.autocommits_when_autocommit_is_off:
connection._start_transaction_under_autocommit()
connection.autocommit = False
else:
connection.set_autocommit(False)
connection.in_atomic_block = True
connection.savepoint_ids.append(None)
def __exit__(self, exc_type, exc_value, traceback):
connection = get_connection(self.using)
sid = connection.savepoint_ids.pop()
if exc_value is None:
if sid is None:
# Commit transaction
connection.in_atomic_block = False
try:
connection.commit()
except DatabaseError:
connection.rollback()
# Remove this when the legacy transaction management goes away.
self._legacy_leave_transaction_management(connection)
raise
finally:
if connection.features.autocommits_when_autocommit_is_off:
connection.autocommit = True
else:
connection.set_autocommit(True)
else:
# Release savepoint
try:
connection.savepoint_commit(sid)
except DatabaseError:
connection.savepoint_rollback(sid)
# Remove this when the legacy transaction management goes away.
self._legacy_leave_transaction_management(connection)
raise
else:
if sid is None:
# Roll back transaction
connection.in_atomic_block = False
try:
connection.rollback()
finally:
if connection.features.autocommits_when_autocommit_is_off:
connection.autocommit = True
else:
connection.set_autocommit(True)
else:
# Roll back to savepoint
connection.savepoint_rollback(sid)
# Remove this when the legacy transaction management goes away.
self._legacy_leave_transaction_management(connection)
def __call__(self, func):
@wraps(func)
def inner(*args, **kwargs):
with self:
return func(*args, **kwargs)
return inner
def atomic(using=None):
# Bare decorator: @atomic -- although the first argument is called
# `using`, it's actually the function being decorated.
if callable(using):
return Atomic(DEFAULT_DB_ALIAS)(using)
# Decorator: @atomic(...) or context manager: with atomic(...): ...
else:
return Atomic(using)
############################################
# Deprecated decorators / context managers #
############################################
class Transaction(object): class Transaction(object):
""" """
@ -279,7 +421,8 @@ def commit_on_success_unless_managed(using=None):
""" """
Transitory API to preserve backwards-compatibility while refactoring. Transitory API to preserve backwards-compatibility while refactoring.
""" """
if get_autocommit(using): connection = get_connection(using)
if connection.autocommit and not connection.in_atomic_block:
return commit_on_success(using) return commit_on_success(using)
else: else:
def entering(using): def entering(using):

View File

@ -1,13 +1,16 @@
============================== =====================
Managing database transactions Database transactions
============================== =====================
.. module:: django.db.transaction .. module:: django.db.transaction
Django gives you a few ways to control how database transactions are managed. Django gives you a few ways to control how database transactions are managed.
Managing database transactions
==============================
Django's default transaction behavior Django's default transaction behavior
===================================== -------------------------------------
Django's default behavior is to run in autocommit mode. Each query is Django's default behavior is to run in autocommit mode. Each query is
immediately committed to the database. :ref:`See below for details immediately committed to the database. :ref:`See below for details
@ -24,7 +27,7 @@ immediately committed to the database. :ref:`See below for details
behavior <transactions-changes-from-1.5>`. behavior <transactions-changes-from-1.5>`.
Tying transactions to HTTP requests Tying transactions to HTTP requests
=================================== -----------------------------------
The recommended way to handle transactions in Web requests is to tie them to The recommended way to handle transactions in Web requests is to tie them to
the request and response phases via Django's ``TransactionMiddleware``. the request and response phases via Django's ``TransactionMiddleware``.
@ -63,6 +66,85 @@ connection internally.
multiple databases and want transaction control over databases other than multiple databases and want transaction control over databases other than
"default", you will need to write your own transaction middleware. "default", you will need to write your own transaction middleware.
Controlling transactions explicitly
-----------------------------------
.. versionadded:: 1.6
Django provides a single API to control database transactions.
.. function:: atomic(using=None)
This function creates an atomic block for writes to the database.
(Atomicity is the defining property of database transactions.)
When the block completes successfully, the changes are committed to the
database. When it raises an exception, the changes are rolled back.
``atomic`` can be nested. In this case, when an inner block completes
successfully, its effects can still be rolled back if an exception is
raised in the outer block at a later point.
``atomic`` takes a ``using`` argument which should be the name of a
database. If this argument isn't provided, Django uses the ``"default"``
database.
``atomic`` is usable both as a decorator::
from django.db import transaction
@transaction.atomic
def viewfunc(request):
# This code executes inside a transaction.
do_stuff()
and as a context manager::
from django.db import transaction
def viewfunc(request):
# This code executes in autocommit mode (Django's default).
do_stuff()
with transaction.atomic():
# This code executes inside a transaction.
do_more_stuff()
Wrapping ``atomic`` in a try/except block allows for natural handling of
integrity errors::
from django.db import IntegrityError, transaction
@transaction.atomic
def viewfunc(request):
do_stuff()
try:
with transaction.atomic():
do_stuff_that_could_fail()
except IntegrityError:
handle_exception()
do_more_stuff()
In this example, even if ``do_stuff_that_could_fail()`` causes a database
error by breaking an integrity constraint, you can execute queries in
``do_more_stuff()``, and the changes from ``do_stuff()`` are still there.
In order to guarantee atomicity, ``atomic`` disables some APIs. Attempting
to commit, roll back, or change the autocommit state of the database
connection within an ``atomic`` block will raise an exception.
``atomic`` can only be used in autocommit mode. It will raise an exception
if autocommit is turned off.
Under the hood, Django's transaction management code:
- opens a transaction when entering the outermost ``atomic`` block;
- creates a savepoint when entering an inner ``atomic`` block;
- releases or rolls back to the savepoint when exiting an inner block;
- commits or rolls back the transaction when exiting the outermost block.
.. _transaction-management-functions: .. _transaction-management-functions:
Controlling transaction management in views Controlling transaction management in views
@ -325,9 +407,8 @@ When autocommit is enabled, savepoints don't make sense. When it's disabled,
commits before any statement other than ``SELECT``, ``INSERT``, ``UPDATE``, commits before any statement other than ``SELECT``, ``INSERT``, ``UPDATE``,
``DELETE`` and ``REPLACE``.) ``DELETE`` and ``REPLACE``.)
As a consequence, savepoints are only usable if you start a transaction As a consequence, savepoints are only usable inside a transaction ie. inside
manually while in autocommit mode, and Django doesn't provide an API to an :func:`atomic` block.
achieve that.
Transactions in MySQL Transactions in MySQL
--------------------- ---------------------

View File

@ -22,4 +22,4 @@ class Reporter(models.Model):
ordering = ('first_name', 'last_name') ordering = ('first_name', 'last_name')
def __str__(self): def __str__(self):
return "%s %s" % (self.first_name, self.last_name) return ("%s %s" % (self.first_name, self.last_name)).strip()

View File

@ -1,11 +1,163 @@
from __future__ import absolute_import from __future__ import absolute_import
import sys
from django.db import connection, transaction, IntegrityError from django.db import connection, transaction, IntegrityError
from django.test import TransactionTestCase, skipUnlessDBFeature from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature
from django.utils import six
from django.utils.unittest import skipUnless
from .models import Reporter from .models import Reporter
@skipUnless(connection.features.uses_savepoints,
"'atomic' requires transactions and savepoints.")
class AtomicTests(TransactionTestCase):
"""
Tests for the atomic decorator and context manager.
The tests make assertions on internal attributes because there isn't a
robust way to ask the database for its current transaction state.
Since the decorator syntax is converted into a context manager (see the
implementation), there are only a few basic tests with the decorator
syntax and the bulk of the tests use the context manager syntax.
"""
def test_decorator_syntax_commit(self):
@transaction.atomic
def make_reporter():
Reporter.objects.create(first_name="Tintin")
make_reporter()
self.assertQuerysetEqual(Reporter.objects.all(), ['<Reporter: Tintin>'])
def test_decorator_syntax_rollback(self):
@transaction.atomic
def make_reporter():
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
with six.assertRaisesRegex(self, Exception, "Oops"):
make_reporter()
self.assertQuerysetEqual(Reporter.objects.all(), [])
def test_alternate_decorator_syntax_commit(self):
@transaction.atomic()
def make_reporter():
Reporter.objects.create(first_name="Tintin")
make_reporter()
self.assertQuerysetEqual(Reporter.objects.all(), ['<Reporter: Tintin>'])
def test_alternate_decorator_syntax_rollback(self):
@transaction.atomic()
def make_reporter():
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
with six.assertRaisesRegex(self, Exception, "Oops"):
make_reporter()
self.assertQuerysetEqual(Reporter.objects.all(), [])
def test_commit(self):
with transaction.atomic():
Reporter.objects.create(first_name="Tintin")
self.assertQuerysetEqual(Reporter.objects.all(), ['<Reporter: Tintin>'])
def test_rollback(self):
with six.assertRaisesRegex(self, Exception, "Oops"):
with transaction.atomic():
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
self.assertQuerysetEqual(Reporter.objects.all(), [])
def test_nested_commit_commit(self):
with transaction.atomic():
Reporter.objects.create(first_name="Tintin")
with transaction.atomic():
Reporter.objects.create(first_name="Archibald", last_name="Haddock")
self.assertQuerysetEqual(Reporter.objects.all(),
['<Reporter: Archibald Haddock>', '<Reporter: Tintin>'])
def test_nested_commit_rollback(self):
with transaction.atomic():
Reporter.objects.create(first_name="Tintin")
with six.assertRaisesRegex(self, Exception, "Oops"):
with transaction.atomic():
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
self.assertQuerysetEqual(Reporter.objects.all(), ['<Reporter: Tintin>'])
def test_nested_rollback_commit(self):
with six.assertRaisesRegex(self, Exception, "Oops"):
with transaction.atomic():
Reporter.objects.create(last_name="Tintin")
with transaction.atomic():
Reporter.objects.create(last_name="Haddock")
raise Exception("Oops, that's his first name")
self.assertQuerysetEqual(Reporter.objects.all(), [])
def test_nested_rollback_rollback(self):
with six.assertRaisesRegex(self, Exception, "Oops"):
with transaction.atomic():
Reporter.objects.create(last_name="Tintin")
with six.assertRaisesRegex(self, Exception, "Oops"):
with transaction.atomic():
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
raise Exception("Oops, that's his first name")
self.assertQuerysetEqual(Reporter.objects.all(), [])
def test_reuse_commit_commit(self):
atomic = transaction.atomic()
with atomic:
Reporter.objects.create(first_name="Tintin")
with atomic:
Reporter.objects.create(first_name="Archibald", last_name="Haddock")
self.assertQuerysetEqual(Reporter.objects.all(),
['<Reporter: Archibald Haddock>', '<Reporter: Tintin>'])
def test_reuse_commit_rollback(self):
atomic = transaction.atomic()
with atomic:
Reporter.objects.create(first_name="Tintin")
with six.assertRaisesRegex(self, Exception, "Oops"):
with atomic:
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
self.assertQuerysetEqual(Reporter.objects.all(), ['<Reporter: Tintin>'])
def test_reuse_rollback_commit(self):
atomic = transaction.atomic()
with six.assertRaisesRegex(self, Exception, "Oops"):
with atomic:
Reporter.objects.create(last_name="Tintin")
with atomic:
Reporter.objects.create(last_name="Haddock")
raise Exception("Oops, that's his first name")
self.assertQuerysetEqual(Reporter.objects.all(), [])
def test_reuse_rollback_rollback(self):
atomic = transaction.atomic()
with six.assertRaisesRegex(self, Exception, "Oops"):
with atomic:
Reporter.objects.create(last_name="Tintin")
with six.assertRaisesRegex(self, Exception, "Oops"):
with atomic:
Reporter.objects.create(first_name="Haddock")
raise Exception("Oops, that's his last name")
raise Exception("Oops, that's his first name")
self.assertQuerysetEqual(Reporter.objects.all(), [])
class AtomicInsideTransactionTests(AtomicTests):
"""All basic tests for atomic should also pass within an existing transaction."""
def setUp(self):
self.atomic = transaction.atomic()
self.atomic.__enter__()
def tearDown(self):
self.atomic.__exit__(*sys.exc_info())
class TransactionTests(TransactionTestCase): class TransactionTests(TransactionTestCase):
def create_a_reporter_then_fail(self, first, last): def create_a_reporter_then_fail(self, first, last):
a = Reporter(first_name=first, last_name=last) a = Reporter(first_name=first, last_name=last)