Refs #27683 -- Allowed setting isolation level in DATABASES ['OPTIONS'] on MySQL.

This commit is contained in:
Tim Graham 2017-01-17 11:16:15 -05:00 committed by GitHub
parent 46e0335583
commit f01ad1cb6a
4 changed files with 113 additions and 6 deletions

View File

@ -219,6 +219,13 @@ class DatabaseWrapper(BaseDatabaseWrapper):
'iendswith': "LIKE CONCAT('%%', {})", 'iendswith': "LIKE CONCAT('%%', {})",
} }
isolation_levels = {
'read uncommitted',
'read committed',
'repeatable read',
'serializable',
}
Database = Database Database = Database
SchemaEditorClass = DatabaseSchemaEditor SchemaEditorClass = DatabaseSchemaEditor
# Classes instantiated in __init__(). # Classes instantiated in __init__().
@ -252,7 +259,23 @@ class DatabaseWrapper(BaseDatabaseWrapper):
# We need the number of potentially affected rows after an # We need the number of potentially affected rows after an
# "UPDATE", not the number of changed rows. # "UPDATE", not the number of changed rows.
kwargs['client_flag'] = CLIENT.FOUND_ROWS kwargs['client_flag'] = CLIENT.FOUND_ROWS
kwargs.update(settings_dict['OPTIONS']) # Validate the transaction isolation level, if specified.
options = settings_dict['OPTIONS'].copy()
isolation_level = options.pop('isolation_level', None)
if isolation_level:
isolation_level = isolation_level.lower()
if isolation_level not in self.isolation_levels:
raise ImproperlyConfigured(
"Invalid transaction isolation level '%s' specified.\n"
"Use one of %s, or None." % (
isolation_level,
', '.join("'%s'" % s for s in sorted(self.isolation_levels))
))
# The variable assignment form of setting transaction isolation
# levels will be used, e.g. "set tx_isolation='repeatable-read'".
isolation_level = isolation_level.replace(' ', '-')
self.isolation_level = isolation_level
kwargs.update(options)
return kwargs return kwargs
def get_new_connection(self, conn_params): def get_new_connection(self, conn_params):
@ -262,13 +285,20 @@ class DatabaseWrapper(BaseDatabaseWrapper):
return conn return conn
def init_connection_state(self): def init_connection_state(self):
assignments = []
if self.features.is_sql_auto_is_null_enabled: if self.features.is_sql_auto_is_null_enabled:
with self.cursor() as cursor:
# SQL_AUTO_IS_NULL controls whether an AUTO_INCREMENT column on # SQL_AUTO_IS_NULL controls whether an AUTO_INCREMENT column on
# a recently inserted row will return when the field is tested # a recently inserted row will return when the field is tested
# for NULL. Disabling this brings this aspect of MySQL in line # for NULL. Disabling this brings this aspect of MySQL in line
# with SQL standards. # with SQL standards.
cursor.execute('SET SQL_AUTO_IS_NULL = 0') assignments.append('SQL_AUTO_IS_NULL = 0')
if self.isolation_level:
assignments.append("TX_ISOLATION = '%s'" % self.isolation_level)
if assignments:
with self.cursor() as cursor:
cursor.execute('SET ' + ', '.join(assignments))
def create_cursor(self, name=None): def create_cursor(self, name=None):
cursor = self.connection.cursor() cursor = self.connection.cursor()

View File

@ -492,6 +492,32 @@ like other MySQL options: either in a config file or with the entry
``'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"`` in the ``'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"`` in the
:setting:`OPTIONS` part of your database configuration in :setting:`DATABASES`. :setting:`OPTIONS` part of your database configuration in :setting:`DATABASES`.
.. _mysql-isolation-level:
Isolation level
~~~~~~~~~~~~~~~
.. versionadded:: 1.11
When running concurrent loads, database transactions from different sessions
(say, separate threads handling different requests) may interact with each
other. These interactions are affected by each session's `transaction isolation
level`_. You can set a connection's isolation level with an
``'isolation_level'`` entry in the :setting:`OPTIONS` part of your database
configuration in :setting:`DATABASES`. Valid values for
this entry are the four standard isolation levels:
* ``'read uncommitted'``
* ``'read committed'``
* ``'repeatable read'``
* ``'serializable'``
or ``None`` to use the server's configured isolation level. However, Django
works best with read committed rather than MySQL's default, repeatable read.
Data loss is possible with repeatable read.
.. _transaction isolation level: https://dev.mysql.com/doc/refman/en/innodb-transaction-isolation-levels.html
Creating your tables Creating your tables
-------------------- --------------------

View File

@ -255,6 +255,11 @@ Database backends
the worker memory load (used to hold query results) to the database and might the worker memory load (used to hold query results) to the database and might
increase database memory usage. increase database memory usage.
* Added MySQL support for the ``'isolation_level'`` option in
:setting:`OPTIONS` to allow specifying the :ref:`transaction isolation level
<mysql-isolation-level>`. To avoid possible data loss, it's recommended to
switch from MySQL's default level, repeatable read, to read committed.
Email Email
~~~~~ ~~~~~

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
import unittest import unittest
from django.core.exceptions import ImproperlyConfigured
from django.db import connection from django.db import connection
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@ -10,6 +11,12 @@ from django.test import TestCase, override_settings
@unittest.skipUnless(connection.vendor == 'mysql', 'MySQL specific test.') @unittest.skipUnless(connection.vendor == 'mysql', 'MySQL specific test.')
class MySQLTests(TestCase): class MySQLTests(TestCase):
@staticmethod
def get_isolation_level(connection):
with connection.cursor() as cursor:
cursor.execute("SELECT @@session.tx_isolation")
return cursor.fetchone()[0]
def test_auto_is_null_auto_config(self): def test_auto_is_null_auto_config(self):
query = 'set sql_auto_is_null = 0' query = 'set sql_auto_is_null = 0'
connection.init_connection_state() connection.init_connection_state()
@ -18,3 +25,42 @@ class MySQLTests(TestCase):
self.assertIn(query, last_query) self.assertIn(query, last_query)
else: else:
self.assertNotIn(query, last_query) self.assertNotIn(query, last_query)
def test_connect_isolation_level(self):
read_committed = 'read committed'
repeatable_read = 'repeatable read'
isolation_values = {
level: level.replace(' ', '-').upper()
for level in (read_committed, repeatable_read)
}
configured_level = connection.isolation_level or isolation_values[repeatable_read]
configured_level = configured_level.upper()
other_level = read_committed if configured_level != isolation_values[read_committed] else repeatable_read
self.assertEqual(self.get_isolation_level(connection), configured_level)
new_connection = connection.copy()
new_connection.settings_dict['OPTIONS']['isolation_level'] = other_level
try:
self.assertEqual(self.get_isolation_level(new_connection), isolation_values[other_level])
finally:
new_connection.close()
# Upper case values are also accepted in 'isolation_level'.
new_connection = connection.copy()
new_connection.settings_dict['OPTIONS']['isolation_level'] = other_level.upper()
try:
self.assertEqual(self.get_isolation_level(new_connection), isolation_values[other_level])
finally:
new_connection.close()
def test_isolation_level_validation(self):
new_connection = connection.copy()
new_connection.settings_dict['OPTIONS']['isolation_level'] = 'xxx'
msg = (
"Invalid transaction isolation level 'xxx' specified.\n"
"Use one of 'read committed', 'read uncommitted', "
"'repeatable read', 'serializable', or None."
)
with self.assertRaisesMessage(ImproperlyConfigured, msg):
new_connection.cursor()