Commit 08a4d11d by Muzaffar yousaf Committed by GitHub

Merge pull request #12378 from edx/usman/atomic-refinement

A more considerate outer_atomic.
parents 70a0b063 78162320
...@@ -3,12 +3,16 @@ Utility functions related to databases. ...@@ -3,12 +3,16 @@ Utility functions related to databases.
""" """
# TransactionManagementError used below actually *does* derive from the standard "Exception" class. # TransactionManagementError used below actually *does* derive from the standard "Exception" class.
# pylint: disable=nonstandard-exception # pylint: disable=nonstandard-exception
from contextlib import contextmanager
from functools import wraps from functools import wraps
import random import random
from django.db import DEFAULT_DB_ALIAS, DatabaseError, Error, transaction from django.db import DEFAULT_DB_ALIAS, DatabaseError, Error, transaction
import request_cache
OUTER_ATOMIC_CACHE_NAME = 'db.outer_atomic'
MYSQL_MAX_INT = (2 ** 31) - 1 MYSQL_MAX_INT = (2 ** 31) - 1
...@@ -142,6 +146,29 @@ def commit_on_success(using=None, read_committed=False): ...@@ -142,6 +146,29 @@ def commit_on_success(using=None, read_committed=False):
return CommitOnSuccessManager(using, read_committed) return CommitOnSuccessManager(using, read_committed)
@contextmanager
def enable_named_outer_atomic(*names):
"""
Enable outer_atomics with names.
Can be used either as a decorator or a context manager.
See docstring of outer_atomic for details.
Arguments:
names (variable-lenght argument list): Names of outer_atomics.
"""
if len(names) == 0:
raise ValueError("At least one name must be specified.")
cache = request_cache.get_cache(OUTER_ATOMIC_CACHE_NAME)
for name in names:
cache[name] = True
yield
for name in names:
del cache[name]
class OuterAtomic(transaction.Atomic): class OuterAtomic(transaction.Atomic):
""" """
Atomic which cannot be nested in another atomic. Atomic which cannot be nested in another atomic.
...@@ -151,36 +178,46 @@ class OuterAtomic(transaction.Atomic): ...@@ -151,36 +178,46 @@ class OuterAtomic(transaction.Atomic):
""" """
ALLOW_NESTED = False ALLOW_NESTED = False
def __init__(self, using, savepoint, read_committed=False): def __init__(self, using, savepoint, read_committed=False, name=None):
self.read_committed = read_committed self.read_committed = read_committed
self.name = name
super(OuterAtomic, self).__init__(using, savepoint) super(OuterAtomic, self).__init__(using, savepoint)
def __enter__(self): def __enter__(self):
connection = transaction.get_connection(self.using) connection = transaction.get_connection(self.using)
# TestCase setup nests tests in two atomics - one for the test class and one for the individual test. cache = request_cache.get_cache(OUTER_ATOMIC_CACHE_NAME)
# The outermost atomic starts a transaction - so does not have a savepoint.
# The inner atomic starts a savepoint around the test.
# So, for tests only, there should be exactly one savepoint_id and two atomic_for_testcase_calls.
# atomic_for_testcase_calls below is added in a monkey-patch for tests only.
if self.ALLOW_NESTED and (self.atomic_for_testcase_calls - len(connection.savepoint_ids)) < 1:
raise transaction.TransactionManagementError('Cannot be inside an atomic block.')
# Otherwise, this shouldn't be nested in any atomic block. # By default it is enabled.
if not self.ALLOW_NESTED and connection.in_atomic_block: enable = True
raise transaction.TransactionManagementError('Cannot be inside an atomic block.') # If name is set it is only enabled if requested by calling enable_named_outer_atomic().
if self.name:
enable = cache.get(self.name, False)
if enable:
# TestCase setup nests tests in two atomics - one for the test class and one for the individual test.
# The outermost atomic starts a transaction - so does not have a savepoint.
# The inner atomic starts a savepoint around the test.
# So, for tests only, there should be exactly one savepoint_id and two atomic_for_testcase_calls.
# atomic_for_testcase_calls below is added in a monkey-patch for tests only.
if self.ALLOW_NESTED and (self.atomic_for_testcase_calls - len(connection.savepoint_ids)) < 1:
raise transaction.TransactionManagementError('Cannot be inside an atomic block.')
# This will set the transaction isolation level to READ COMMITTED for the next transaction. # Otherwise, this shouldn't be nested in any atomic block.
if self.read_committed is True: if not self.ALLOW_NESTED and connection.in_atomic_block:
if connection.vendor == 'mysql': raise transaction.TransactionManagementError('Cannot be inside an atomic block.')
cursor = connection.cursor()
cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") # This will set the transaction isolation level to READ COMMITTED for the next transaction.
if self.read_committed is True:
if connection.vendor == 'mysql':
cursor = connection.cursor()
cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
super(OuterAtomic, self).__enter__() super(OuterAtomic, self).__enter__()
def outer_atomic(using=None, savepoint=True, read_committed=False): def outer_atomic(using=None, savepoint=True, read_committed=False, name=None):
""" """
A variant of Django's atomic() which cannot be nested inside another atomic. A variant of Django's atomic() which cannot be nested inside another atomic.
...@@ -200,6 +237,14 @@ def outer_atomic(using=None, savepoint=True, read_committed=False): ...@@ -200,6 +237,14 @@ def outer_atomic(using=None, savepoint=True, read_committed=False):
outer_atomic(). outer_atomic() will ensure that it is not nested outer_atomic(). outer_atomic() will ensure that it is not nested
inside another atomic block. inside another atomic block.
If we need to do this to prevent IntegrityErrors, a named outer_atomic
should be used. You can create a named outer_atomic by passing a name.
A named outer_atomic only checks that it is not nested under an atomic
only if it is nested under enable_named_outer_atomic(name=<name>). This way
only the view which is causing IntegrityErrors needs to have its
automatic transaction management disabled and other callers are not
affected.
Additionally, some views need to use READ COMMITTED isolation level. Additionally, some views need to use READ COMMITTED isolation level.
For this add @transaction.non_atomic_requests and For this add @transaction.non_atomic_requests and
@outer_atomic(read_committed=True) decorators on it. @outer_atomic(read_committed=True) decorators on it.
...@@ -207,6 +252,7 @@ def outer_atomic(using=None, savepoint=True, read_committed=False): ...@@ -207,6 +252,7 @@ def outer_atomic(using=None, savepoint=True, read_committed=False):
Arguments: Arguments:
using (str): the name of the database. using (str): the name of the database.
read_committed (bool): Whether to use read committed isolation level. read_committed (bool): Whether to use read committed isolation level.
name (str): the name to give to this outer_atomic instance.
Raises: Raises:
TransactionManagementError: if already inside an atomic block. TransactionManagementError: if already inside an atomic block.
...@@ -215,7 +261,7 @@ def outer_atomic(using=None, savepoint=True, read_committed=False): ...@@ -215,7 +261,7 @@ def outer_atomic(using=None, savepoint=True, read_committed=False):
return OuterAtomic(DEFAULT_DB_ALIAS, savepoint, read_committed)(using) return OuterAtomic(DEFAULT_DB_ALIAS, savepoint, read_committed)(using)
# Decorator: @outer_atomic(...) or context manager: with outer_atomic(...): ... # Decorator: @outer_atomic(...) or context manager: with outer_atomic(...): ...
else: else:
return OuterAtomic(using, savepoint, read_committed) return OuterAtomic(using, savepoint, read_committed, name)
def generate_int_id(minimum=0, maximum=MYSQL_MAX_INT, used_ids=None): def generate_int_id(minimum=0, maximum=MYSQL_MAX_INT, used_ids=None):
......
...@@ -13,7 +13,14 @@ from django.db import connection, IntegrityError ...@@ -13,7 +13,14 @@ from django.db import connection, IntegrityError
from django.db.transaction import atomic, TransactionManagementError from django.db.transaction import atomic, TransactionManagementError
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from util.db import commit_on_success, generate_int_id, outer_atomic, NoOpMigrationModules from util.db import (
commit_on_success, enable_named_outer_atomic, outer_atomic, generate_int_id, NoOpMigrationModules
)
def do_nothing():
"""Just return."""
return
@ddt.ddt @ddt.ddt
...@@ -88,14 +95,9 @@ class TransactionManagersTestCase(TransactionTestCase): ...@@ -88,14 +95,9 @@ class TransactionManagersTestCase(TransactionTestCase):
Test that outer_atomic raises an error if it is nested inside Test that outer_atomic raises an error if it is nested inside
another atomic. another atomic.
""" """
if connection.vendor != 'mysql': if connection.vendor != 'mysql':
raise unittest.SkipTest('Only works on MySQL.') raise unittest.SkipTest('Only works on MySQL.')
def do_nothing():
"""Just return."""
return
outer_atomic()(do_nothing)() outer_atomic()(do_nothing)()
with atomic(): with atomic():
...@@ -123,10 +125,6 @@ class TransactionManagersTestCase(TransactionTestCase): ...@@ -123,10 +125,6 @@ class TransactionManagersTestCase(TransactionTestCase):
if connection.vendor != 'mysql': if connection.vendor != 'mysql':
raise unittest.SkipTest('Only works on MySQL.') raise unittest.SkipTest('Only works on MySQL.')
def do_nothing():
"""Just return."""
return
commit_on_success(read_committed=True)(do_nothing)() commit_on_success(read_committed=True)(do_nothing)()
with self.assertRaisesRegexp(TransactionManagementError, 'Cannot change isolation level when nested.'): with self.assertRaisesRegexp(TransactionManagementError, 'Cannot change isolation level when nested.'):
...@@ -137,6 +135,53 @@ class TransactionManagersTestCase(TransactionTestCase): ...@@ -137,6 +135,53 @@ class TransactionManagersTestCase(TransactionTestCase):
with atomic(): with atomic():
commit_on_success(read_committed=True)(do_nothing)() commit_on_success(read_committed=True)(do_nothing)()
def test_named_outer_atomic_nesting(self):
"""
Test that a named outer_atomic raises an error only if nested in
enable_named_outer_atomic and inside another atomic.
"""
if connection.vendor != 'mysql':
raise unittest.SkipTest('Only works on MySQL.')
outer_atomic(name='abc')(do_nothing)()
with atomic():
outer_atomic(name='abc')(do_nothing)()
with enable_named_outer_atomic('abc'):
outer_atomic(name='abc')(do_nothing)() # Not nested.
with atomic():
outer_atomic(name='pqr')(do_nothing)() # Not enabled.
with self.assertRaisesRegexp(TransactionManagementError, 'Cannot be inside an atomic block.'):
with atomic():
outer_atomic(name='abc')(do_nothing)()
with enable_named_outer_atomic('abc', 'def'):
outer_atomic(name='def')(do_nothing)() # Not nested.
with atomic():
outer_atomic(name='pqr')(do_nothing)() # Not enabled.
with self.assertRaisesRegexp(TransactionManagementError, 'Cannot be inside an atomic block.'):
with atomic():
outer_atomic(name='def')(do_nothing)()
with self.assertRaisesRegexp(TransactionManagementError, 'Cannot be inside an atomic block.'):
with outer_atomic():
outer_atomic(name='def')(do_nothing)()
with self.assertRaisesRegexp(TransactionManagementError, 'Cannot be inside an atomic block.'):
with atomic():
outer_atomic(name='abc')(do_nothing)()
with self.assertRaisesRegexp(TransactionManagementError, 'Cannot be inside an atomic block.'):
with outer_atomic():
outer_atomic(name='abc')(do_nothing)()
@ddt.ddt @ddt.ddt
class GenerateIntIdTestCase(TestCase): class GenerateIntIdTestCase(TestCase):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment