Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- An error will be raised when opening a transaction if there are pre-existing unhandled after-commit callbacks.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should mention here that this is a tests-only feature. Or we could check it outside tests too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This failure-mode is only possible in tests. You're right though that I should make that clearer.

The pre-existing callbacks would previously run when `transaction` exits.
This helps catch order-of-execution bugs in tests.
The old behavior can be restored using `settings.SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep this feature tests-only, we should have TEST in the setting name.

to facilitate gradual adoption of this stricter rule.

### Fixed

- Ensure cleanup actions in `durable` always happen when the wrapped code raises an unexpected error.
Expand Down
28 changes: 27 additions & 1 deletion src/django_subatomic/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,23 @@ def _execute_on_commit_callbacks_in_tests(using: str | None = None) -> Generator
- Django 4.2's `run_and_clear_commit_hooks` function:
https://github.com/django/django/blob/stable/4.2.x/django/db/backends/base/base.py#L762-L779
"""
only_in_testcase_transaction = _innermost_atomic_block_wraps_testcase(using=using)

if (
getattr(settings, "SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS", True)
and only_in_testcase_transaction
):
connection = django_transaction.get_connection(using)
callbacks = connection.run_on_commit
if callbacks:
raise _UnhandledCallbacks(tuple(callback for _, callback, _ in callbacks))

yield

if (
# See Note [Running after-commit callbacks in tests]
getattr(settings, "SUBATOMIC_RUN_AFTER_COMMIT_CALLBACKS_IN_TESTS", True)
and _innermost_atomic_block_wraps_testcase(using=using)
and only_in_testcase_transaction
):
connection = django_transaction.get_connection(using)
callbacks = connection.run_on_commit
Expand Down Expand Up @@ -284,6 +296,20 @@ class _UnexpectedDanglingTransaction(Exception):
open_dbs: frozenset[str]


@attrs.frozen
class _UnhandledCallbacks(Exception):
"""
Raised in tests when unhandled callbacks are found before opening a transaction.

This happens when after-commit callbacks are registered
but not run before trying to open a database transaction.

The best solution is to ensure the after-commit callbacks are run.
"""

callbacks: tuple[Callable[[], object], ...]
Comment on lines +309 to +310
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I'm not sure if it's a good idea go add these callbacks to the exception. 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine.



# Note [After-commit callbacks require a transaction]
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# After-commit callbacks may only be registered when a transaction is open.
Expand Down
94 changes: 94 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,100 @@ def raises() -> None:
assert error_raised is True
assert counter.count == 1

@pytest.mark.parametrize(
"transaction_manager",
(db.transaction, db.transaction_if_not_already),
)
def test_unhandled_callbacks_cause_error(
self, transaction_manager: DBContextManager
) -> None:
"""
If callbacks from a previous atomic context remain, raise an error.
"""
counter = Counter()

# Django's `atomic` leaves unhandled after-commit actions on exit.
with django_transaction.atomic():
db.run_after_commit(counter.increment)

# `transaction` will raise when it finds the unhandled callback.
with pytest.raises(db._UnhandledCallbacks) as exc_info: # noqa: SLF001
with transaction_manager():
...

assert counter.count == 0
assert exc_info.value.callbacks == (counter.increment,)

@pytest.mark.parametrize(
"transaction_manager",
(db.transaction, db.transaction_if_not_already),
)
def test_unhandled_callbacks_check_can_be_disabled(
self, transaction_manager: DBContextManager
) -> None:
"""
We can disable the check for unhandled callbacks.
"""
counter = Counter()

# Django's `atomic` leaves unhandled after-commit actions on exit.
with django_transaction.atomic():
db.run_after_commit(counter.increment)

# Run after-commit callbacks when `transaction` exits,
# even if that means running them later than is realistic.
with override_settings(SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS=False):
with transaction_manager():
assert counter.count == 0

assert counter.count == 1

@pytest.mark.parametrize(
"transaction_manager",
(db.transaction, db.transaction_if_not_already),
)
def test_handled_callbacks_are_not_an_error(
self, transaction_manager: DBContextManager
) -> None:
"""
Already-handled checks do not cause an error.
"""
counter = Counter()

# Callbacks are handled by `transaction` and removed from the queue.
with db.transaction():
db.run_after_commit(counter.increment)
assert counter.count == 0
assert counter.count == 1

# The callbacks have been handled, so a second `transaction` does not raise.
with transaction_manager():
pass

# The callback was not run a second time.
assert counter.count == 1

@pytest.mark.parametrize(
"transaction_manager",
(db.transaction, db.transaction_if_not_already),
)
def test_callbacks_ignored_by_transaction_if_not_already(
self, transaction_manager: DBContextManager
) -> None:
"""
`transaction_if_not_already` ignores after-commit callbacks if a transaction already exists.
"""
counter = Counter()

with transaction_manager():
db.run_after_commit(counter.increment)
with db.transaction_if_not_already():
assert counter.count == 0
assert counter.count == 0

# The callback is run when the outermost transaction exits.
assert counter.count == 1


class TestTransactionRequired:
@_parametrize_transaction_testcase
Expand Down