Skip to content

Commit 810aeb0

Browse files
meshysamueljsb
andcommitted
Raise error when encountering unhandled callbacks
Before now, exiting `transaction` would run all after-commit callbacks, on the assumption that they were enqueued inside the transaction. In tests this sometimes hid order-of-execution bugs, where pre-existing unhandled after-commit callbacks would get called, but at the wrong time. When opening a transaction, we will now check for pre-existing unhandled after-commit callbacks, and raise an error when they're found. Co-authored-by: Samuel Searles-Bryant <sam.searles-bryant@kraken.tech>
1 parent 14a8898 commit 810aeb0

File tree

4 files changed

+150
-1
lines changed

4 files changed

+150
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010
### Added
1111

1212
- Django 6.0 has been added to the test matrix.
13+
- In tests, an error will now be raised when opening a transaction if there are pre-existing unhandled after-commit callbacks.
14+
The pre-existing callbacks would previously run when `transaction` exits.
15+
This helps catch order-of-execution bugs in tests.
16+
The error can be silenced by setting the `SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS_IN_TESTS` setting to `False`
17+
to facilitate gradual adoption of this stricter rule.
1318

1419
### Fixed
1520

docs/reference/settings.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,25 @@ progressively enable after-commit callbacks in tests
3030
by using [`override_settings`][override_settings]
3131
on a per-test basis.
3232

33+
## `SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS_IN_TESTS`
34+
35+
(default: `True`)
36+
37+
[`transaction`][django_subatomic.db.transaction]
38+
will raise `_UnhandledCallbacks` in tests
39+
if it detects any lingering unhandled after-commit callbacks
40+
when it's called.
41+
Note: because this exception represents a programming error,
42+
it starts with an underscore to discourage anyone from catching it.
43+
44+
This highlights order-of-execution issues in tests
45+
caused by after-commit callbacks having not been run.
46+
This can only happen in tests without after-commit callback simulation
47+
(such as those using Django's `atomic` directly),
48+
because in live systems after commit callbacks are always handled or discarded.
49+
50+
The error can be silenced by setting this to `False`,
51+
in which case, the lingering callbacks will be run by the transaction after it commits.
52+
3353
[override_settings]: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.override_settings
3454
[django-settings]: https://docs.djangoproject.com/en/stable/topics/settings/

src/django_subatomic/db.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,25 @@ def _execute_on_commit_callbacks_in_tests(using: str | None = None) -> Generator
194194
- Django 4.2's `run_and_clear_commit_hooks` function:
195195
https://github.com/django/django/blob/stable/4.2.x/django/db/backends/base/base.py#L762-L779
196196
"""
197+
only_in_testcase_transaction = _innermost_atomic_block_wraps_testcase(using=using)
198+
199+
if (
200+
getattr(
201+
settings, "SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS_IN_TESTS", True
202+
)
203+
and only_in_testcase_transaction
204+
):
205+
connection = django_transaction.get_connection(using)
206+
callbacks = connection.run_on_commit
207+
if callbacks:
208+
raise _UnhandledCallbacks(tuple(callback for _, callback, _ in callbacks))
209+
197210
yield
211+
198212
if (
199213
# See Note [Running after-commit callbacks in tests]
200214
getattr(settings, "SUBATOMIC_RUN_AFTER_COMMIT_CALLBACKS_IN_TESTS", True)
201-
and _innermost_atomic_block_wraps_testcase(using=using)
215+
and only_in_testcase_transaction
202216
):
203217
connection = django_transaction.get_connection(using)
204218
callbacks = connection.run_on_commit
@@ -284,6 +298,20 @@ class _UnexpectedDanglingTransaction(Exception):
284298
open_dbs: frozenset[str]
285299

286300

301+
@attrs.frozen
302+
class _UnhandledCallbacks(Exception):
303+
"""
304+
Raised in tests when unhandled callbacks are found before opening a transaction.
305+
306+
This happens when after-commit callbacks are registered
307+
but not run before trying to open a database transaction.
308+
309+
The best solution is to ensure the after-commit callbacks are run.
310+
"""
311+
312+
callbacks: tuple[Callable[[], object], ...]
313+
314+
287315
# Note [After-commit callbacks require a transaction]
288316
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
289317
# After-commit callbacks may only be registered when a transaction is open.

tests/test_db.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,102 @@ def raises() -> None:
194194
assert error_raised is True
195195
assert counter.count == 1
196196

197+
@pytest.mark.parametrize(
198+
"transaction_manager",
199+
(db.transaction, db.transaction_if_not_already),
200+
)
201+
def test_unhandled_callbacks_cause_error(
202+
self, transaction_manager: DBContextManager
203+
) -> None:
204+
"""
205+
If callbacks from a previous atomic context remain, raise an error.
206+
"""
207+
counter = Counter()
208+
209+
# Django's `atomic` leaves unhandled after-commit actions on exit.
210+
with django_transaction.atomic():
211+
db.run_after_commit(counter.increment)
212+
213+
# `transaction` will raise when it finds the unhandled callback.
214+
with pytest.raises(db._UnhandledCallbacks) as exc_info: # noqa: SLF001
215+
with transaction_manager():
216+
...
217+
218+
assert counter.count == 0
219+
assert exc_info.value.callbacks == (counter.increment,)
220+
221+
@pytest.mark.parametrize(
222+
"transaction_manager",
223+
(db.transaction, db.transaction_if_not_already),
224+
)
225+
def test_unhandled_callbacks_check_can_be_disabled(
226+
self, transaction_manager: DBContextManager
227+
) -> None:
228+
"""
229+
We can disable the check for unhandled callbacks.
230+
"""
231+
counter = Counter()
232+
233+
# Django's `atomic` leaves unhandled after-commit actions on exit.
234+
with django_transaction.atomic():
235+
db.run_after_commit(counter.increment)
236+
237+
# Run after-commit callbacks when `transaction` exits,
238+
# even if that means running them later than is realistic.
239+
with override_settings(
240+
SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS_IN_TESTS=False
241+
):
242+
with transaction_manager():
243+
assert counter.count == 0
244+
245+
assert counter.count == 1
246+
247+
@pytest.mark.parametrize(
248+
"transaction_manager",
249+
(db.transaction, db.transaction_if_not_already),
250+
)
251+
def test_handled_callbacks_are_not_an_error(
252+
self, transaction_manager: DBContextManager
253+
) -> None:
254+
"""
255+
Already-handled checks do not cause an error.
256+
"""
257+
counter = Counter()
258+
259+
# Callbacks are handled by `transaction` and removed from the queue.
260+
with db.transaction():
261+
db.run_after_commit(counter.increment)
262+
assert counter.count == 0
263+
assert counter.count == 1
264+
265+
# The callbacks have been handled, so a second `transaction` does not raise.
266+
with transaction_manager():
267+
pass
268+
269+
# The callback was not run a second time.
270+
assert counter.count == 1
271+
272+
@pytest.mark.parametrize(
273+
"transaction_manager",
274+
(db.transaction, db.transaction_if_not_already),
275+
)
276+
def test_callbacks_ignored_by_transaction_if_not_already(
277+
self, transaction_manager: DBContextManager
278+
) -> None:
279+
"""
280+
`transaction_if_not_already` ignores after-commit callbacks if a transaction already exists.
281+
"""
282+
counter = Counter()
283+
284+
with transaction_manager():
285+
db.run_after_commit(counter.increment)
286+
with db.transaction_if_not_already():
287+
assert counter.count == 0
288+
assert counter.count == 0
289+
290+
# The callback is run when the outermost transaction exits.
291+
assert counter.count == 1
292+
197293

198294
class TestTransactionRequired:
199295
@_parametrize_transaction_testcase

0 commit comments

Comments
 (0)