Skip to content

Commit 4166b08

Browse files
RyanSkonnordryan953
authored andcommitted
feat(hybrid): Introduce region function contracts (#39253)
Set up a type system for region objects and the set of all regions, with placeholders for backend operations not implemented yet.
1 parent cbf2ec6 commit 4166b08

File tree

9 files changed

+252
-11
lines changed

9 files changed

+252
-11
lines changed

mypy.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ files = src/sentry/analytics/,
108108
src/sentry/tasks/symbolication.py,
109109
src/sentry/tasks/update_user_reports.py,
110110
src/sentry/testutils/silo.py,
111+
src/sentry/types/region.py,
111112
src/sentry/unmerge.py,
112113
src/sentry/utils/appleconnect/,
113114
src/sentry/utils/hashlib.py,

src/sentry/conf/server.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import sys
1111
import tempfile
1212
from datetime import datetime, timedelta
13-
from typing import Mapping
13+
from typing import Iterable
1414
from urllib.parse import urlparse
1515

1616
import sentry
@@ -2787,7 +2787,6 @@ def build_cdc_postgres_init_db_volume(settings):
27872787
MAX_REDIS_SNOWFLAKE_RETRY_COUNTER = 5
27882788

27892789
SNOWFLAKE_VERSION_ID = 1
2790-
SNOWFLAKE_REGION_ID = 0
27912790
SENTRY_SNOWFLAKE_EPOCH_START = datetime(2022, 8, 8, 0, 0).timestamp()
27922791
SENTRY_USE_SNOWFLAKE = False
27932792

@@ -2819,4 +2818,5 @@ def build_cdc_postgres_init_db_volume(settings):
28192818

28202819
SENTRY_PERFORMANCE_ISSUES_RATE_LIMITER_OPTIONS = {}
28212820

2822-
SENTRY_REGION_CONFIG: Mapping[str, Region] = {}
2821+
SENTRY_REGION = os.environ.get("SENTRY_REGION", None)
2822+
SENTRY_REGION_CONFIG: Iterable[Region] = ()

src/sentry/testutils/cases.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
override_options,
141141
parse_queries,
142142
)
143+
from .silo import exempt_from_silo_limits
143144
from .skips import requires_snuba
144145

145146
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"
@@ -239,7 +240,8 @@ def login_as(
239240
user.backend = settings.AUTHENTICATION_BACKENDS[0]
240241

241242
request = self.make_request()
242-
login(request, user)
243+
with exempt_from_silo_limits():
244+
login(request, user)
243245
request.user = user
244246

245247
if organization_ids is None:

src/sentry/testutils/region.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from contextlib import contextmanager
2+
from typing import Iterable
3+
from unittest import mock
4+
5+
from sentry.types.region import Region, _RegionMapping
6+
7+
8+
@contextmanager
9+
def override_regions(regions: Iterable[Region]):
10+
"""Override the global set of existing regions.
11+
12+
The overriding value takes the place of the `SENTRY_REGION_CONFIG` setting and
13+
changes the behavior of the module-level functions in `sentry.types.region`. This
14+
is preferable to overriding the `SENTRY_REGION_CONFIG` setting value directly
15+
because the region mapping may already be cached.
16+
"""
17+
18+
mapping = _RegionMapping(regions)
19+
20+
def override() -> _RegionMapping:
21+
return mapping
22+
23+
with mock.patch("sentry.types.region._load_global_regions", new=override):
24+
yield

src/sentry/types/region.py

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,134 @@
1+
from __future__ import annotations
2+
3+
import functools
14
from dataclasses import dataclass
5+
from enum import Enum
6+
from typing import TYPE_CHECKING, Iterable
7+
8+
from sentry.silo import SiloMode
9+
10+
if TYPE_CHECKING:
11+
from sentry.models import Organization
12+
13+
14+
class RegionCategory(Enum):
15+
MULTI_TENANT = "MULTI_TENANT"
16+
SINGLE_TENANT = "SINGLE_TENANT"
217

318

4-
@dataclass
19+
@dataclass(frozen=True, eq=True)
520
class Region:
21+
"""A region of the Sentry platform, hosted by a region silo."""
22+
623
name: str
7-
subdomain: str
8-
is_private: bool = False
24+
"""The region's unique identifier."""
25+
26+
id: int
27+
"""The region's unique numeric representation.
28+
29+
This number is used for composing "snowflake" IDs, and must fit inside the
30+
maximum bit length specified by our snowflake ID schema.
31+
"""
32+
33+
address: str
34+
"""The address of the region's silo.
35+
36+
Represent a region's hostname or subdomain in a production environment
37+
(e.g., "eu.sentry.io"), and addresses such as "localhost:8001" in a dev
38+
environment.
39+
40+
(This attribute is a placeholder. Please update this docstring when its
41+
contract becomes more stable.)
42+
"""
43+
44+
category: RegionCategory
45+
"""The region's category."""
46+
47+
def __post_init__(self) -> None:
48+
from sentry.utils.snowflake import NULL_REGION_ID, REGION_ID
49+
50+
REGION_ID.validate(self.id)
51+
if self.id == NULL_REGION_ID:
52+
raise ValueError(f"Region ID {NULL_REGION_ID} is reserved for non-multi-region systems")
53+
54+
def to_url(self, path: str) -> str:
55+
"""Resolve a path into a URL on this region's silo.
56+
57+
(This method is a placeholder. See the `address` attribute.)
58+
"""
59+
return self.address + path
60+
61+
62+
class RegionResolutionError(Exception):
63+
"""Indicate that a region's identity could not be resolved."""
64+
65+
66+
class RegionContextError(Exception):
67+
"""Indicate that the server is not in a state to resolve a region."""
68+
69+
70+
class _RegionMapping:
71+
"""The set of all regions in this Sentry platform instance."""
72+
73+
def __init__(self, regions: Iterable[Region]) -> None:
74+
self.regions = frozenset(regions)
75+
self.by_name = {r.name: r for r in self.regions}
76+
self.by_id = {r.id: r for r in self.regions}
77+
78+
79+
@functools.lru_cache(maxsize=1)
80+
def _load_global_regions() -> _RegionMapping:
81+
from django.conf import settings
82+
83+
# For now, assume that all region configs can be taken in through Django
84+
# settings. We may investigate other ways of delivering those configs in
85+
# production.
86+
return _RegionMapping(settings.SENTRY_REGION_CONFIG)
87+
88+
89+
def get_region_by_name(name: str) -> Region:
90+
"""Look up a region by name."""
91+
try:
92+
return _load_global_regions().by_name[name]
93+
except KeyError:
94+
raise RegionResolutionError(f"No region with name: {name!r}")
95+
96+
97+
def get_region_by_id(id: int) -> Region:
98+
"""Look up a region by numeric ID."""
99+
try:
100+
return _load_global_regions().by_id[id]
101+
except KeyError:
102+
raise RegionResolutionError(f"No region with numeric ID: {id}")
103+
104+
105+
def get_region_for_organization(organization: Organization) -> Region:
106+
"""Resolve an organization to the region where its data is stored.
107+
108+
Raises RegionContextError if this Sentry platform instance is configured to
109+
run only in monolith mode.
110+
"""
111+
mapping = _load_global_regions()
112+
113+
if not mapping.regions:
114+
raise RegionContextError("No regions are configured")
115+
116+
# Backend representation to be determined. If you are working on code
117+
# that depends on this method, you can mock it out in unit tests or
118+
# temporarily hard-code a placeholder.
119+
raise NotImplementedError
120+
121+
122+
def get_local_region() -> Region | None:
123+
"""Get the region in which this server instance is running.
124+
125+
Raises RegionContextError if this server instance is not a region silo.
126+
"""
127+
from django.conf import settings
128+
129+
if SiloMode.get_current_mode() != SiloMode.REGION:
130+
raise RegionContextError("Not a region silo")
131+
132+
if not settings.SENTRY_REGION:
133+
raise Exception("SENTRY_REGION must be set when server is in REGION silo mode")
134+
return get_region_by_name(settings.SENTRY_REGION)

src/sentry/utils/snowflake.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from rest_framework import status
88
from rest_framework.exceptions import APIException
99

10+
from sentry.types.region import RegionContextError, get_local_region
1011
from sentry.utils import redis
1112

1213
_TTL = timedelta(minutes=5)
@@ -60,6 +61,8 @@ def validate(self, value):
6061
MAX_AVAILABLE_REGION_SEQUENCES = 1 << REGION_SEQUENCE.length
6162
assert MAX_AVAILABLE_REGION_SEQUENCES > 0
6263

64+
NULL_REGION_ID = 0
65+
6366

6467
def msb_0_ordering(value, width):
6568
"""
@@ -72,10 +75,14 @@ def msb_0_ordering(value, width):
7275

7376

7477
def generate_snowflake_id(redis_key: str) -> int:
75-
segment_values = {
76-
VERSION_ID: msb_0_ordering(settings.SNOWFLAKE_VERSION_ID, VERSION_ID.length),
77-
REGION_ID: settings.SNOWFLAKE_REGION_ID,
78-
}
78+
segment_values = {}
79+
80+
segment_values[VERSION_ID] = msb_0_ordering(settings.SNOWFLAKE_VERSION_ID, VERSION_ID.length)
81+
82+
try:
83+
segment_values[REGION_ID] = get_local_region().id
84+
except RegionContextError: # expected if running in monolith mode
85+
segment_values[REGION_ID] = NULL_REGION_ID
7986

8087
current_time = datetime.now().timestamp()
8188
# supports up to 130 years

tests/sentry/types/__init__.py

Whitespace-only changes.

tests/sentry/types/test_region.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import pytest
2+
from django.test import override_settings
3+
4+
from sentry.silo import SiloMode
5+
from sentry.testutils import TestCase
6+
from sentry.testutils.region import override_regions
7+
from sentry.types.region import (
8+
Region,
9+
RegionCategory,
10+
RegionContextError,
11+
RegionResolutionError,
12+
get_local_region,
13+
get_region_by_id,
14+
get_region_by_name,
15+
get_region_for_organization,
16+
)
17+
18+
19+
class RegionMappingTest(TestCase):
20+
def test_region_mapping(self):
21+
regions = [
22+
Region("north_america", 1, "na.sentry.io", RegionCategory.MULTI_TENANT),
23+
Region("europe", 2, "eu.sentry.io", RegionCategory.MULTI_TENANT),
24+
Region("acme-single-tenant", 3, "acme.my.sentry.io", RegionCategory.SINGLE_TENANT),
25+
]
26+
with override_regions(regions):
27+
assert get_region_by_id(1) == regions[0]
28+
assert get_region_by_name("europe") == regions[1]
29+
30+
with pytest.raises(RegionResolutionError):
31+
get_region_by_id(4)
32+
with pytest.raises(RegionResolutionError):
33+
get_region_by_name("nowhere")
34+
35+
def test_get_for_organization(self):
36+
with override_regions(()):
37+
org = self.create_organization()
38+
with pytest.raises(RegionContextError):
39+
get_region_for_organization(org)
40+
41+
def test_get_local_region(self):
42+
regions = [
43+
Region("north_america", 1, "na.sentry.io", RegionCategory.MULTI_TENANT),
44+
Region("europe", 2, "eu.sentry.io", RegionCategory.MULTI_TENANT),
45+
]
46+
47+
with override_regions(regions):
48+
with override_settings(SILO_MODE=SiloMode.REGION, SENTRY_REGION="north_america"):
49+
assert get_local_region() == regions[0]
50+
51+
with override_settings(SILO_MODE=SiloMode.MONOLITH):
52+
with pytest.raises(RegionContextError):
53+
get_local_region()

tests/sentry/utils/test_snowflake.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22

33
import pytest
44
from django.conf import settings
5+
from django.test import override_settings
56
from freezegun import freeze_time
67

8+
from sentry.silo import SiloMode
79
from sentry.testutils import TestCase
10+
from sentry.testutils.region import override_regions
11+
from sentry.types.region import Region, RegionCategory
12+
from sentry.utils import snowflake
813
from sentry.utils.snowflake import (
914
_TTL,
1015
MAX_AVAILABLE_REGION_SEQUENCES,
16+
SnowflakeBitSegment,
1117
generate_snowflake_id,
1218
get_redis_cluster,
1319
)
@@ -57,3 +63,25 @@ def test_out_of_region_sequences(self):
5763
generate_snowflake_id("test_redis_key")
5864

5965
assert str(context.value) == "No available ID"
66+
67+
@freeze_time(CURRENT_TIME)
68+
def test_generate_correct_ids_with_region_id(self):
69+
regions = [
70+
Region("test-region-1", 1, "localhost:8001", RegionCategory.MULTI_TENANT),
71+
Region("test-region-2", 2, "localhost:8002", RegionCategory.MULTI_TENANT),
72+
]
73+
with override_regions(regions):
74+
75+
with override_settings(SILO_MODE=SiloMode.REGION, SENTRY_REGION="test-region-1"):
76+
snowflake1 = generate_snowflake_id("test_redis_key")
77+
with override_settings(SILO_MODE=SiloMode.REGION, SENTRY_REGION="test-region-2"):
78+
snowflake2 = generate_snowflake_id("test_redis_key")
79+
80+
def recover_segment_value(segment: SnowflakeBitSegment, value: int) -> int:
81+
for s in reversed(snowflake.BIT_SEGMENT_SCHEMA):
82+
if s == segment:
83+
return value & ((1 << s.length) - 1)
84+
value >>= s.length
85+
86+
assert recover_segment_value(snowflake.REGION_ID, snowflake1) == regions[0].id
87+
assert recover_segment_value(snowflake.REGION_ID, snowflake2) == regions[1].id

0 commit comments

Comments
 (0)