Skip to content

Commit 840abb6

Browse files
committed
INTPYTHON-355 Add transaction.atomic()
1 parent 6d38abb commit 840abb6

File tree

16 files changed

+688
-9
lines changed

16 files changed

+688
-9
lines changed

django_mongodb_backend/base.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import contextlib
2+
import logging
23
import os
34

45
from django.core.exceptions import ImproperlyConfigured
6+
from django.db import DEFAULT_DB_ALIAS
57
from django.db.backends.base.base import BaseDatabaseWrapper
8+
from django.db.backends.utils import debug_transaction
69
from django.utils.asyncio import async_unsafe
710
from django.utils.functional import cached_property
811
from pymongo.collection import Collection
@@ -32,6 +35,9 @@ def __exit__(self, exception_type, exception_value, exception_traceback):
3235
pass
3336

3437

38+
logger = logging.getLogger("django.db.backends.base")
39+
40+
3541
class DatabaseWrapper(BaseDatabaseWrapper):
3642
data_types = {
3743
"AutoField": "int",
@@ -142,6 +148,17 @@ def _isnull_operator(a, b):
142148
ops_class = DatabaseOperations
143149
validation_class = DatabaseValidation
144150

151+
def __init__(self, settings_dict, alias=DEFAULT_DB_ALIAS):
152+
super().__init__(settings_dict, alias=alias)
153+
self.session = None
154+
# Tracks whether the connection is in a transaction managed by
155+
# django_mongodb_backend.transaction.atomic. `in_atomic_block` isn't
156+
# used in case Django's atomic() (used internally in Django) is called
157+
# within this package's atomic().
158+
self.in_atomic_block_mongo = False
159+
# Current number of nested 'atomic' calls.
160+
self.nested_atomics = 0
161+
145162
def get_collection(self, name, **kwargs):
146163
collection = Collection(self.database, name, **kwargs)
147164
if self.queries_logged:
@@ -212,6 +229,10 @@ def close(self):
212229

213230
def close_pool(self):
214231
"""Close the MongoClient."""
232+
# Clear commit hooks and session.
233+
self.run_on_commit = []
234+
if self.session:
235+
self._end_session()
215236
connection = self.connection
216237
if connection is None:
217238
return
@@ -230,3 +251,57 @@ def cursor(self):
230251
def get_database_version(self):
231252
"""Return a tuple of the database's version."""
232253
return tuple(self.connection.server_info()["versionArray"])
254+
255+
## Transaction API for django_mongodb_backend.transaction.atomic()
256+
@async_unsafe
257+
def start_transaction_mongo(self):
258+
if self.session is None:
259+
self.session = self.connection.start_session()
260+
with debug_transaction(self, "session.start_transaction()"):
261+
self.session.start_transaction()
262+
263+
@async_unsafe
264+
def commit_mongo(self):
265+
if self.session:
266+
with debug_transaction(self, "session.commit_transaction()"):
267+
self.session.commit_transaction()
268+
self._end_session()
269+
self.run_and_clear_commit_hooks()
270+
271+
@async_unsafe
272+
def rollback_mongo(self):
273+
if self.session:
274+
with debug_transaction(self, "session.abort_transaction()"):
275+
self.session.abort_transaction()
276+
self._end_session()
277+
self.run_on_commit = []
278+
279+
def _end_session(self):
280+
self.session.end_session()
281+
self.session = None
282+
283+
def on_commit(self, func, robust=False):
284+
"""
285+
Copied from BaseDatabaseWrapper.on_commit() except that it checks
286+
in_atomic_block_mongo instead of in_atomic_block.
287+
"""
288+
if not callable(func):
289+
raise TypeError("on_commit()'s callback must be a callable.")
290+
if self.in_atomic_block_mongo:
291+
# Transaction in progress; save for execution on commit.
292+
# The first item in the tuple (an empty list) is normally the
293+
# savepoint IDs, which isn't applicable on MongoDB.
294+
self.run_on_commit.append(([], func, robust))
295+
else:
296+
# No transaction in progress; execute immediately.
297+
if robust:
298+
try:
299+
func()
300+
except Exception as e:
301+
logger.exception(
302+
"Error calling %s in on_commit() (%s).",
303+
func.__qualname__,
304+
e,
305+
)
306+
else:
307+
func()

django_mongodb_backend/compiler.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,9 @@ def execute_sql(self, returning_fields=None):
696696
@wrap_database_errors
697697
def insert(self, docs, returning_fields=None):
698698
"""Store a list of documents using field columns as element names."""
699-
inserted_ids = self.collection.insert_many(docs).inserted_ids
699+
inserted_ids = self.collection.insert_many(
700+
docs, session=self.connection.session
701+
).inserted_ids
700702
return [(x,) for x in inserted_ids] if returning_fields else []
701703

702704
@cached_property
@@ -777,7 +779,9 @@ def execute_sql(self, result_type):
777779

778780
@wrap_database_errors
779781
def update(self, criteria, pipeline):
780-
return self.collection.update_many(criteria, pipeline).matched_count
782+
return self.collection.update_many(
783+
criteria, pipeline, session=self.connection.session
784+
).matched_count
781785

782786
def check_query(self):
783787
super().check_query()

django_mongodb_backend/query.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,19 @@ def delete(self):
6363
"""Execute a delete query."""
6464
if self.compiler.subqueries:
6565
raise NotSupportedError("Cannot use QuerySet.delete() when a subquery is required.")
66-
return self.compiler.collection.delete_many(self.match_mql).deleted_count
66+
return self.compiler.collection.delete_many(
67+
self.match_mql, session=self.compiler.connection.session
68+
).deleted_count
6769

6870
@wrap_database_errors
6971
def get_cursor(self):
7072
"""
7173
Return a pymongo CommandCursor that can be iterated on to give the
7274
results of the query.
7375
"""
74-
return self.compiler.collection.aggregate(self.get_pipeline())
76+
return self.compiler.collection.aggregate(
77+
self.get_pipeline(), session=self.compiler.connection.session
78+
)
7579

7680
def get_pipeline(self):
7781
pipeline = []

django_mongodb_backend/queryset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def __init__(self, pipeline, using, model):
3535
def _execute_query(self):
3636
connection = connections[self.using]
3737
collection = connection.get_collection(self.model._meta.db_table)
38-
self.cursor = collection.aggregate(self.pipeline)
38+
self.cursor = collection.aggregate(self.pipeline, session=connection.session)
3939

4040
def __str__(self):
4141
return str(self.pipeline)

django_mongodb_backend/transaction.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from contextlib import ContextDecorator
2+
3+
from django.db import DEFAULT_DB_ALIAS, DatabaseError
4+
from django.db.transaction import get_connection, on_commit
5+
6+
__all__ = [
7+
"atomic",
8+
"on_commit", # convenience alias
9+
]
10+
11+
12+
class Atomic(ContextDecorator):
13+
"""
14+
Guarantee the atomic execution of a given block.
15+
16+
Simplified from django.db.transaction.
17+
"""
18+
19+
def __init__(self, using):
20+
self.using = using
21+
22+
def __enter__(self):
23+
connection = get_connection(self.using)
24+
if connection.in_atomic_block_mongo:
25+
# Track the number of nested atomic() calls.
26+
connection.nested_atomics += 1
27+
else:
28+
# Start a transaction for the outermost atomic().
29+
connection.start_transaction_mongo()
30+
connection.in_atomic_block_mongo = True
31+
32+
def __exit__(self, exc_type, exc_value, traceback):
33+
connection = get_connection(self.using)
34+
if connection.nested_atomics:
35+
# Exiting inner atomic.
36+
connection.nested_atomics -= 1
37+
else:
38+
# Reset flag when exiting outer atomic.
39+
connection.in_atomic_block_mongo = False
40+
if exc_type is None:
41+
# atomic() exited without an error.
42+
if not connection.in_atomic_block_mongo:
43+
# Commit transaction if outer atomic().
44+
try:
45+
connection.commit_mongo()
46+
except DatabaseError:
47+
connection.rollback_mongo()
48+
else:
49+
# atomic() exited with an error.
50+
if not connection.in_atomic_block_mongo:
51+
# Rollback transaction if outer atomic().
52+
connection.rollback_mongo()
53+
54+
55+
def atomic(using=None):
56+
# Bare decorator: @atomic -- although the first argument is called `using`,
57+
# it's actually the function being decorated.
58+
if callable(using):
59+
return Atomic(DEFAULT_DB_ALIAS)(using)
60+
# Decorator: @atomic(...) or context manager: with atomic(...): ...
61+
return Atomic(using)

docs/source/releases/5.2.x.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ New features
1313
- Added subquery support for :class:`~.fields.EmbeddedModelArrayField`.
1414
- Added the ``options`` parameter to
1515
:func:`~django_mongodb_backend.utils.parse_uri`.
16+
- Added support for :ref:`database transactions <transactions>`.
1617
- Added :class:`~.fields.PolymorphicEmbeddedModelField` and
1718
:class:`~.fields.PolymorphicEmbeddedModelArrayField` for storing a model
1819
instance or list of model instances that may be of more than one model class.

docs/source/topics/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ know:
99
:maxdepth: 2
1010

1111
embedded-models
12+
transactions
1213
known-issues

docs/source/topics/known-issues.rst

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,12 @@ Database functions
8080
Transaction management
8181
======================
8282

83-
Query execution uses Django and MongoDB's default behavior of autocommit mode.
84-
Each query is immediately committed to the database.
83+
By default, query execution uses Django and MongoDB's default behavior of autocommit
84+
mode. Each query is immediately committed to the database.
8585

8686
Django's :doc:`transaction management APIs <django:topics/db/transactions>`
87-
are not supported.
87+
are not supported. Instead, this package provides its own :doc:`transaction APIs
88+
</topics/transactions>`.
8889

8990
Database introspection
9091
======================

docs/source/topics/transactions.rst

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
============
2+
Transactions
3+
============
4+
5+
.. versionadded:: 5.2.0b2
6+
7+
.. module:: django_mongodb_backend.transaction
8+
9+
MongoDB supports :doc:`transactions <manual:core/transactions>` if it's
10+
configured as a :doc:`replica set <manual:replication>` or a :doc:`sharded
11+
cluster <manual:sharding>`.
12+
13+
Because MongoDB transactions have some limitations and are not meant to be used
14+
as freely as SQL transactions, :doc:`Django's transactions APIs
15+
<django:topics/db/transactions>`, including most notably
16+
:func:`django.db.transaction.atomic`, function as no-ops.
17+
18+
Instead, Django MongoDB Backend provides its own
19+
:func:`django_mongodb_backend.transaction.atomic` function.
20+
21+
Outside of a transaction, query execution uses Django and MongoDB's default
22+
behavior of autocommit mode. Each query is immediately committed to the
23+
database.
24+
25+
Controlling transactions
26+
========================
27+
28+
.. function:: atomic(using=None)
29+
30+
Atomicity is the defining property of database transactions. ``atomic``
31+
allows creating a block of code within which the atomicity on the database
32+
is guaranteed. If the block of code is successfully completed, the changes
33+
are committed to the database. If there is an exception, the changes are
34+
rolled back.
35+
36+
``atomic`` is usable both as a :py:term:`decorator`::
37+
38+
from django_mongodb_backend import transaction
39+
40+
41+
@transaction.atomic
42+
def viewfunc(request):
43+
# This code executes inside a transaction.
44+
do_stuff()
45+
46+
and as a :py:term:`context manager`::
47+
48+
from django_mongodb_backend import transaction
49+
50+
51+
def viewfunc(request):
52+
# This code executes in autocommit mode (Django's default).
53+
do_stuff()
54+
55+
with transaction.atomic():
56+
# This code executes inside a transaction.
57+
do_more_stuff()
58+
59+
.. admonition:: Avoid catching exceptions inside ``atomic``!
60+
61+
When exiting an ``atomic`` block, Django looks at whether it's exited
62+
normally or with an exception to determine whether to commit or roll
63+
back. If you catch and handle exceptions inside an ``atomic`` block,
64+
you may hide from Django the fact that a problem has happened. This can
65+
result in unexpected behavior.
66+
67+
This is mostly a concern for :exc:`~django.db.DatabaseError` and its
68+
subclasses such as :exc:`~django.db.IntegrityError`. After such an
69+
error, the transaction is broken and Django will perform a rollback at
70+
the end of the ``atomic`` block.
71+
72+
.. admonition:: You may need to manually revert app state when rolling back a transaction.
73+
74+
The values of a model's fields won't be reverted when a transaction
75+
rollback happens. This could lead to an inconsistent model state unless
76+
you manually restore the original field values.
77+
78+
For example, given ``MyModel`` with an ``active`` field, this snippet
79+
ensures that the ``if obj.active`` check at the end uses the correct
80+
value if updating ``active`` to ``True`` fails in the transaction::
81+
82+
from django_mongodb_backend import transaction
83+
from django.db import DatabaseError
84+
85+
obj = MyModel(active=False)
86+
obj.active = True
87+
try:
88+
with transaction.atomic():
89+
obj.save()
90+
except DatabaseError:
91+
obj.active = False
92+
93+
if obj.active:
94+
...
95+
96+
This also applies to any other mechanism that may hold app state, such
97+
as caching or global variables. For example, if the code proactively
98+
updates data in the cache after saving an object, it's recommended to
99+
use :ref:`transaction.on_commit() <performing-actions-after-commit>`
100+
instead, to defer cache alterations until the transaction is actually
101+
committed.
102+
103+
``atomic`` takes a ``using`` argument which should be the name of a
104+
database. If this argument isn't provided, Django uses the ``"default"``
105+
database.
106+
107+
.. admonition:: Performance considerations
108+
109+
Open transactions have a performance cost for your MongoDB server. To
110+
minimize this overhead, keep your transactions as short as possible. This
111+
is especially important if you're using :func:`atomic` in long-running
112+
processes, outside of Django's request / response cycle.
113+
114+
Performing actions after commit
115+
===============================
116+
117+
The :func:`atomic` function supports Django's
118+
:func:`~django.db.transaction.on_commit` API to :ref:`perform actions after a
119+
transaction successfully commits <performing-actions-after-commit>`.
120+
121+
For convenience, :func:`~django.db.transaction.on_commit` is aliased at
122+
``django_mongodb_backend.transaction.on_commit`` so you can use both::
123+
124+
from django_mongodb_backend import transaction
125+
126+
127+
transaction.atomic()
128+
transaction.on_commit(...)
129+
130+
.. _transactions-limitations:
131+
132+
Limitations
133+
===========
134+
135+
MongoDB's transaction limitations that are applicable to Django are:
136+
137+
- :meth:`QuerySet.union() <django.db.models.query.QuerySet.union>` is not
138+
supported inside a transaction.
139+
- Savepoints (i.e. nested :func:`~django.db.transaction.atomic` blocks) aren't
140+
supported. The outermost :func:`~django.db.transaction.atomic` will start
141+
a transaction while any inner :func:`~django.db.transaction.atomic` blocks
142+
have no effect.

0 commit comments

Comments
 (0)