Skip to content

Commit 263f581

Browse files
committed
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.
1 parent e8ffe7a commit 263f581

File tree

3 files changed

+129
-1
lines changed

3 files changed

+129
-1
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- An error will be raised when opening a transaction if there are pre-existing unhandled after-commit callbacks.
13+
The pre-existing callbacks would previously run when `transaction` exits.
14+
This helps catch order-of-execution bugs in tests.
15+
The old behavior can be restored using `settings.SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS`
16+
to facilitate gradual adoption of this stricter rule.
17+
1018
### Fixed
1119

1220
- Ensure cleanup actions in `durable` always happen when the wrapped code raises an unexpected error.

src/django_subatomic/db.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,23 @@ 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(settings, "SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS", True)
201+
and only_in_testcase_transaction
202+
):
203+
connection = django_transaction.get_connection(using)
204+
callbacks = connection.run_on_commit
205+
if callbacks:
206+
raise _UnhandledCallbacks(tuple(callback for _, callback, _ in callbacks))
207+
197208
yield
209+
198210
if (
199211
# See Note [Running after-commit callbacks in tests]
200212
getattr(settings, "SUBATOMIC_RUN_AFTER_COMMIT_CALLBACKS_IN_TESTS", True)
201-
and _innermost_atomic_block_wraps_testcase(using=using)
213+
and only_in_testcase_transaction
202214
):
203215
connection = django_transaction.get_connection(using)
204216
callbacks = connection.run_on_commit
@@ -284,6 +296,20 @@ class _UnexpectedDanglingTransaction(Exception):
284296
open_dbs: frozenset[str]
285297

286298

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

tests/test_db.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,100 @@ 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(SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS=False):
240+
with transaction_manager():
241+
assert counter.count == 0
242+
243+
assert counter.count == 1
244+
245+
@pytest.mark.parametrize(
246+
"transaction_manager",
247+
(db.transaction, db.transaction_if_not_already),
248+
)
249+
def test_handled_callbacks_are_not_an_error(
250+
self, transaction_manager: DBContextManager
251+
) -> None:
252+
"""
253+
Already-handled checks do not cause an error.
254+
"""
255+
counter = Counter()
256+
257+
# Callbacks are handled by `transaction` and removed from the queue.
258+
with db.transaction():
259+
db.run_after_commit(counter.increment)
260+
assert counter.count == 0
261+
assert counter.count == 1
262+
263+
# The callbacks have been handled, so a second `transaction` does not raise.
264+
with transaction_manager():
265+
pass
266+
267+
# The callback was not run a second time.
268+
assert counter.count == 1
269+
270+
@pytest.mark.parametrize(
271+
"transaction_manager",
272+
(db.transaction, db.transaction_if_not_already),
273+
)
274+
def test_callbacks_ignored_by_transaction_if_not_already(
275+
self, transaction_manager: DBContextManager
276+
) -> None:
277+
"""
278+
`transaction_if_not_already` ignores after-commit callbacks if a transaction already exists.
279+
"""
280+
counter = Counter()
281+
282+
with transaction_manager():
283+
db.run_after_commit(counter.increment)
284+
with db.transaction_if_not_already():
285+
assert counter.count == 0
286+
assert counter.count == 0
287+
288+
# The callback is run when the outermost transaction exits.
289+
assert counter.count == 1
290+
197291

198292
class TestTransactionRequired:
199293
@_parametrize_transaction_testcase

0 commit comments

Comments
 (0)