Skip to content

Commit 9b816ea

Browse files
ref(hybrid-cloud): Require using param when creating transactions or using unguarded_write decorators (#53160)
With the inclusion of our recent changes to transaction validations in PR #52943 and the inclusion of 'using' in all of our transactions/helpers thanks to PRs #53027 and #53004, we should be able to safely remove the ability to specify a transaction without a using keyword arg. Because this is now mandatory, it should also be safe to remove autorouting, which ended up being incorrect in many testing contexts anyway. This is a resubmission of PR #53080 which was reverted due to broken load-mocks and a single broken getsentry migration test.
1 parent 1fbfcc5 commit 9b816ea

File tree

8 files changed

+52
-47
lines changed

8 files changed

+52
-47
lines changed

src/sentry/models/organization.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
sane_repr,
3030
)
3131
from sentry.db.models.utils import slugify_instance
32+
from sentry.db.postgres.transactions import in_test_hide_transaction_boundary
3233
from sentry.locks import locks
3334
from sentry.models.options.option import OptionMixin
3435
from sentry.models.organizationmember import OrganizationMember
@@ -323,7 +324,8 @@ def get_owners(self) -> Sequence[RpcUser]:
323324
"user_id", flat=True
324325
)
325326

326-
return user_service.get_many(filter={"user_ids": list(owners)})
327+
with in_test_hide_transaction_boundary():
328+
return user_service.get_many(filter={"user_ids": list(owners)})
327329

328330
def get_default_owner(self) -> RpcUser:
329331
if not hasattr(self, "_default_owner"):

src/sentry/services/hybrid_cloud/auth/impl.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def authenticate(self, *, request: AuthenticationRequest) -> MiddlewareAuthentic
193193
elif fake_request.user is not None and not fake_request.user.is_anonymous:
194194
with transaction.atomic(using=router.db_for_read(User)):
195195
result.user = self._load_auth_user(fake_request.user)
196-
transaction.set_rollback(True)
196+
transaction.set_rollback(True, using=router.db_for_read(User))
197197
if SiloMode.single_process_silo_mode():
198198
connections.close_all()
199199

src/sentry/silo/patches/silo_aware_transaction_patch.py

+13-10
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class MismatchedSiloTransactionError(Exception):
1616
pass
1717

1818

19+
class TransactionMissingDBException(Exception):
20+
pass
21+
22+
1923
def _get_db_for_model_if_available(model: Type["Model"]) -> Optional[str]:
2024
from sentry.db.router import SiloConnectionUnavailableError
2125

@@ -28,36 +32,36 @@ def _get_db_for_model_if_available(model: Type["Model"]) -> Optional[str]:
2832
def siloed_atomic(
2933
using: Optional[str] = None, savepoint: bool = True, durable: bool = False
3034
) -> Atomic:
31-
using = determine_using_by_silo_mode(using)
35+
validate_transaction_using_for_silo_mode(using)
3236
return _default_atomic_impl(using=using, savepoint=savepoint, durable=durable)
3337

3438

3539
def siloed_get_connection(using: Optional[str] = None) -> BaseDatabaseWrapper:
36-
using = determine_using_by_silo_mode(using)
40+
validate_transaction_using_for_silo_mode(using)
3741
return _default_get_connection(using=using)
3842

3943

4044
def siloed_on_commit(func: Callable[..., Any], using: Optional[str] = None) -> None:
41-
using = determine_using_by_silo_mode(using)
45+
validate_transaction_using_for_silo_mode(using)
4246
return _default_on_commit(func, using)
4347

4448

45-
def determine_using_by_silo_mode(using: Optional[str]) -> str:
49+
def validate_transaction_using_for_silo_mode(using: Optional[str]):
4650
from sentry.models import ControlOutbox, RegionOutbox
4751
from sentry.silo import SiloMode
4852

53+
if using is None:
54+
raise TransactionMissingDBException("'using' must be specified when creating a transaction")
55+
4956
current_silo_mode = SiloMode.get_current_mode()
5057
control_db = _get_db_for_model_if_available(ControlOutbox)
5158
region_db = _get_db_for_model_if_available(RegionOutbox)
5259

53-
if not using:
54-
using = region_db if current_silo_mode == SiloMode.REGION else control_db
55-
assert using
56-
5760
both_silos_route_to_same_db = control_db == region_db
5861

5962
if both_silos_route_to_same_db or current_silo_mode == SiloMode.MONOLITH:
60-
pass
63+
return
64+
6165
elif using == control_db and current_silo_mode != SiloMode.CONTROL:
6266
raise MismatchedSiloTransactionError(
6367
f"Cannot use transaction.atomic({using}) except in Control Mode"
@@ -67,7 +71,6 @@ def determine_using_by_silo_mode(using: Optional[str]) -> str:
6771
raise MismatchedSiloTransactionError(
6872
f"Cannot use transaction.atomic({using}) except in Region Mode"
6973
)
70-
return using
7174

7275

7376
def patch_silo_aware_atomic():

src/sentry/silo/safety.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
from django.db.transaction import get_connection
1010

11-
from sentry.silo.patches.silo_aware_transaction_patch import determine_using_by_silo_mode
11+
from sentry.silo.patches.silo_aware_transaction_patch import (
12+
validate_transaction_using_for_silo_mode,
13+
)
1214

1315
_fence_re = re.compile(r"select\s*\'(?P<operation>start|end)_role_override", re.IGNORECASE)
1416
_fencing_counters: MutableMapping[str, int] = defaultdict(int)
@@ -19,7 +21,7 @@ def match_fence_query(query: str) -> Optional[re.Match[str]]:
1921

2022

2123
@contextlib.contextmanager
22-
def unguarded_write(using: str | None = None, *args: Any, **kwargs: Any):
24+
def unguarded_write(using: str, *args: Any, **kwargs: Any):
2325
"""
2426
Used to indicate that the wrapped block is safe to do
2527
mutations on outbox backed records.
@@ -37,7 +39,8 @@ def unguarded_write(using: str | None = None, *args: Any, **kwargs: Any):
3739
yield
3840
return
3941

40-
using = determine_using_by_silo_mode(using)
42+
validate_transaction_using_for_silo_mode(using)
43+
4144
_fencing_counters[using] += 1
4245

4346
with get_connection(using).cursor() as conn:

src/sentry/testutils/factories.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from django.utils.text import slugify
2222

2323
from sentry.constants import SentryAppInstallationStatus, SentryAppStatus
24-
from sentry.db.postgres.transactions import in_test_hide_transaction_boundary
2524
from sentry.event_manager import EventManager
2625
from sentry.incidents.logic import (
2726
create_alert_rule,
@@ -377,10 +376,9 @@ def create_project(organization=None, teams=None, fire_project_created=False, **
377376
for team in teams:
378377
project.add_team(team)
379378
if fire_project_created:
380-
with in_test_hide_transaction_boundary():
381-
project_created.send(
382-
project=project, user=AnonymousUser(), default_rules=True, sender=Factories
383-
)
379+
project_created.send(
380+
project=project, user=AnonymousUser(), default_rules=True, sender=Factories
381+
)
384382
return project
385383

386384
@staticmethod

src/sentry/testutils/hybrid_cloud.py

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from django.db import connections, transaction
2323
from django.db.backends.base.base import BaseDatabaseWrapper
2424

25+
from sentry.db.postgres.transactions import in_test_transaction_enforcement
2526
from sentry.models.organizationmember import OrganizationMember
2627
from sentry.models.organizationmembermapping import OrganizationMemberMapping
2728
from sentry.services.hybrid_cloud import DelegatedBySiloMode, hc_test_stub
@@ -182,6 +183,9 @@ def __init__(self, alias: str):
182183
self.alias = alias
183184

184185
def __call__(self, execute: Callable[..., Any], *params: Any) -> Any:
186+
if not in_test_transaction_enforcement.enabled:
187+
return execute(*params)
188+
185189
open_transactions = simulated_transaction_watermarks.connections_above_watermark()
186190
# If you are hitting this, it means you have two open transactions working in differing databases at the same
187191
# time. This is problematic in general for a variety of reasons -- it will never be possible to atomically

src/sentry/web/frontend/unsubscribe_notifications.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import abc
22

3-
from django.db import transaction
3+
from django.db import router, transaction
44
from django.http import Http404, HttpResponse, HttpResponseRedirect
55
from django.utils.decorators import method_decorator
66
from django.views.decorators.cache import never_cache
@@ -19,7 +19,7 @@ class UnsubscribeBaseView(BaseView, metaclass=abc.ABCMeta):
1919
@never_cache
2020
@signed_auth_required_m
2121
def handle(self, request: Request, **kwargs) -> HttpResponse:
22-
with transaction.atomic():
22+
with transaction.atomic(using=router.db_for_write(OrganizationMember)):
2323
if not getattr(request, "user_from_signed_request", False):
2424
raise Http404
2525

tests/sentry/silo/test_silo_aware_transaction_patch.py

+20-25
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sentry.silo import SiloMode
99
from sentry.silo.patches.silo_aware_transaction_patch import (
1010
MismatchedSiloTransactionError,
11+
TransactionMissingDBException,
1112
siloed_atomic,
1213
)
1314
from sentry.testutils import TestCase
@@ -18,47 +19,41 @@ def is_running_in_split_db_mode() -> bool:
1819

1920

2021
class TestSiloAwareTransactionPatchInSingleDbMode(TestCase):
21-
@pytest.mark.skipif(is_running_in_split_db_mode(), reason="only runs in single db mode")
22-
def test_routes_to_correct_db_in_control_silo(self):
23-
with override_settings(SILO_MODE=SiloMode.CONTROL):
24-
transaction_in_test = siloed_atomic()
25-
assert transaction_in_test.using == "default"
26-
27-
@pytest.mark.skipif(is_running_in_split_db_mode(), reason="only runs in single db mode")
28-
def test_routes_to_correct_db_in_region_silo(self):
29-
30-
with override_settings(SILO_MODE=SiloMode.REGION):
31-
transaction_in_test = siloed_atomic()
32-
assert transaction_in_test.using == "default"
33-
3422
def test_correctly_accepts_using_for_atomic(self):
3523
transaction_in_test = siloed_atomic(using="foobar")
3624
assert transaction_in_test.using == "foobar"
3725

26+
def test_accepts_cross_silo_atomics_in_monolith_mode(self):
27+
siloed_atomic(using=router.db_for_write(Organization))
28+
siloed_atomic(using=router.db_for_write(OrganizationMapping))
3829

39-
class TestSiloAwareTransactionPatchInSplitDbMode(TestCase):
40-
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
41-
def test_routes_to_correct_db_in_control_silo(self):
42-
with override_settings(SILO_MODE=SiloMode.REGION):
43-
transaction_in_test = siloed_atomic()
44-
assert transaction_in_test.using == "default"
4530

31+
class TestSiloAwareTransactionPatchInSplitDbMode(TestCase):
4632
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
4733
def test_fails_if_silo_mismatch_with_using_in_region_silo(self):
4834
with override_settings(SILO_MODE=SiloMode.REGION), pytest.raises(
4935
MismatchedSiloTransactionError
5036
):
5137
siloed_atomic(using=router.db_for_write(OrganizationMapping))
5238

53-
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
54-
def test_routes_to_correct_db_in_region_silo(self):
55-
with override_settings(SILO_MODE=SiloMode.CONTROL):
56-
transaction_in_test = siloed_atomic()
57-
assert transaction_in_test.using == "control"
58-
5939
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
6040
def test_fails_if_silo_mismatch_with_using_in_control_silo(self):
6141
with override_settings(SILO_MODE=SiloMode.CONTROL), pytest.raises(
6242
MismatchedSiloTransactionError
6343
):
6444
siloed_atomic(using=router.db_for_write(Organization))
45+
46+
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
47+
def test_fails_if_no_using_provided(self):
48+
with pytest.raises(TransactionMissingDBException):
49+
siloed_atomic()
50+
51+
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
52+
def test_accepts_control_silo_routing_in_control_silo(self):
53+
with override_settings(SILO_MODE=SiloMode.CONTROL):
54+
siloed_atomic(using=router.db_for_write(OrganizationMapping))
55+
56+
@pytest.mark.skipif(not is_running_in_split_db_mode(), reason="only runs in split db mode")
57+
def test_accepts_control_silo_routing_in_region_silo(self):
58+
with override_settings(SILO_MODE=SiloMode.REGION):
59+
siloed_atomic(using=router.db_for_write(Organization))

0 commit comments

Comments
 (0)