Skip to content

Commit 2ffec79

Browse files
committed
feat(backup): Enable export over RPC
Exports are already done on a sequential, per-model basis, going in reverse dependency order. This change modifies that scheme to work across an RPC boundary: code running on REGION instances (the only kind of instance that may perform import/export operations) requests the exports using either the local implementation (if the model is REGION as well), or by sending an RPC call to the same method on the remote CONTROL silo instance. Code executed in MONOLITH mode (aka, self-hosted and single tenant instances) does all exporting locally. The new `import_export_service` is thus a bit different from some of the others in `hybrid_cloud`. Whereas existing services like `Access` provide a means to get the same information using different logic depending on which Silo they are located in (ex: using `Organization` or `OrganizationMapping` as the source of truth), this service executes identical logic in all silo kinds, and requires users to pick the implementation themselves. That is, users must examine the model they are about to request be exported, and make their own decision as to which implementation, remote or local, to use. This is a bit unergonomic compared to other `hybrid_cloud` services, but it is only used by one rather unique endpoint (the import/export functions) for a very specific purpose, making this API a bit easier to swallow. Issue: getsentry/team-ospo#196
1 parent 6f91716 commit 2ffec79

File tree

14 files changed

+766
-183
lines changed

14 files changed

+766
-183
lines changed

src/sentry/backup/dependencies.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass
55
from enum import Enum, auto, unique
66
from functools import lru_cache
7-
from typing import Dict, FrozenSet, NamedTuple, Optional, Set, Tuple, Type
7+
from typing import NamedTuple, Optional, Tuple, Type
88

99
from django.db import models
1010
from django.db.models.fields.related import ForeignKey, OneToOneField
@@ -150,6 +150,9 @@ def get_possible_relocation_scopes(self) -> set[RelocationScope]:
150150
return self.model.get_possible_relocation_scopes()
151151
return set()
152152

153+
def get_dependencies_for_relocation(self) -> set[Type[models.base.Model]]:
154+
return self.flatten().union(self.relocation_dependencies)
155+
153156
def get_uniques_without_foreign_keys(self) -> list[frozenset[str]]:
154157
"""
155158
Gets all unique sets (that is, either standalone fields that are marked `unique=True`, or
@@ -177,7 +180,7 @@ def get_uniques_without_foreign_keys(self) -> list[frozenset[str]]:
177180
return out
178181

179182

180-
def get_model_name(model: type[models.Model] | models.Model) -> NormalizedModelName:
183+
def get_model_name(model: Type[models.Model] | models.Model) -> NormalizedModelName:
181184
return NormalizedModelName(f"{model._meta.app_label}.{model._meta.object_name}")
182185

183186

@@ -217,7 +220,7 @@ def default(self, obj):
217220
return super().default(obj)
218221

219222

220-
class ImportKind(Enum):
223+
class ImportKind(str, Enum):
221224
"""
222225
When importing a given model, we may create a new copy of it (`Inserted`), merely re-use an
223226
`Existing` copy that has the same already-used globally unique identifier (ex: `username` for
@@ -227,9 +230,9 @@ class ImportKind(Enum):
227230
if they are dealing with a new or re-used model.
228231
"""
229232

230-
Inserted = auto()
231-
Existing = auto()
232-
Overwrite = auto()
233+
Inserted = "Inserted"
234+
Existing = "Existing"
235+
Overwrite = "Overwrite"
233236

234237

235238
class PrimaryKeyMap:
@@ -244,7 +247,7 @@ class PrimaryKeyMap:
244247
keys are not supported!
245248
"""
246249

247-
mapping: Dict[str, Dict[int, Tuple[int, ImportKind, Optional[str]]]]
250+
mapping: dict[str, dict[int, Tuple[int, ImportKind, Optional[str]]]]
248251

249252
def __init__(self):
250253
self.mapping = defaultdict(dict)
@@ -264,7 +267,7 @@ def get_pk(self, model_name: NormalizedModelName, old: int) -> Optional[int]:
264267

265268
return entry[0]
266269

267-
def get_pks(self, model_name: NormalizedModelName) -> Set[int]:
270+
def get_pks(self, model_name: NormalizedModelName) -> set[int]:
268271
"""
269272
Get a list of all of the pks for a specific model.
270273
"""
@@ -315,6 +318,31 @@ def insert(
315318

316319
self.mapping[str(model_name)][old] = (new, kind, slug)
317320

321+
def extend(self, other: PrimaryKeyMap) -> None:
322+
"""
323+
Insert all values from another map into this one, without mutating the original map.
324+
"""
325+
326+
for model_name_str, mappings in other.mapping.items():
327+
for old_pk, new_entry in mappings.items():
328+
self.mapping[model_name_str][old_pk] = new_entry
329+
330+
def partition(self, model_names: set[NormalizedModelName]) -> PrimaryKeyMap:
331+
"""
332+
Create a new map with only the specified model kinds retained.
333+
"""
334+
335+
building = PrimaryKeyMap()
336+
for model_name_str, mappings in self.mapping.items():
337+
model_name = NormalizedModelName(model_name_str)
338+
if model_name not in model_names:
339+
continue
340+
341+
for old_pk, new_entry in mappings.items():
342+
building.mapping[model_name_str][old_pk] = new_entry
343+
344+
return building
345+
318346

319347
# No arguments, so we lazily cache the result after the first calculation.
320348
@lru_cache(maxsize=1)
@@ -336,7 +364,7 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
336364
from sentry.models.team import Team
337365

338366
# Process the list of models, and get the list of dependencies.
339-
model_dependencies_dict: Dict[NormalizedModelName, ModelRelations] = {}
367+
model_dependencies_dict: dict[NormalizedModelName, ModelRelations] = {}
340368
app_configs = apps.get_app_configs()
341369
models_from_names = {
342370
get_model_name(model): model
@@ -351,8 +379,8 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
351379
model_iterator = app_config.get_models()
352380

353381
for model in model_iterator:
354-
foreign_keys: Dict[str, ForeignField] = dict()
355-
uniques: Set[FrozenSet[str]] = {
382+
foreign_keys: dict[str, ForeignField] = dict()
383+
uniques: set[frozenset[str]] = {
356384
frozenset(combo) for combo in model._meta.unique_together
357385
}
358386

@@ -485,7 +513,7 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
485513
# models non-dangling, then traversing from every other model to a (possible) root model
486514
# recursively. At this point there should be no circular reference chains, so if we encounter
487515
# them, fail immediately.
488-
def resolve_dangling(seen: Set[NormalizedModelName], model_name: NormalizedModelName) -> bool:
516+
def resolve_dangling(seen: set[NormalizedModelName], model_name: NormalizedModelName) -> bool:
489517
model_relations = model_dependencies_dict[model_name]
490518
model_name = get_model_name(model_relations.model)
491519
if model_name in seen:
@@ -557,7 +585,7 @@ def sorted_dependencies() -> list[Type[models.base.Model]]:
557585
changed = False
558586
while model_dependencies_remaining:
559587
model_deps = model_dependencies_remaining.pop()
560-
deps = model_deps.flatten().union(model_deps.relocation_dependencies)
588+
deps = model_deps.get_dependencies_for_relocation()
561589
model = model_deps.model
562590

563591
# If all of the models in the dependency list are either already

0 commit comments

Comments
 (0)