Skip to content

Commit 0ef3704

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 30e058c commit 0ef3704

File tree

14 files changed

+766
-184
lines changed

14 files changed

+766
-184
lines changed

src/sentry/backup/dependencies.py

Lines changed: 41 additions & 14 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
@@ -147,6 +147,9 @@ def get_possible_relocation_scopes(self) -> set[RelocationScope]:
147147
return self.model.get_possible_relocation_scopes()
148148
return set()
149149

150+
def get_dependencies_for_relocation(self) -> set[Type[models.base.Model]]:
151+
return self.flatten().union(self.relocation_dependencies)
152+
150153
def get_uniques_without_foreign_keys(self) -> list[frozenset[str]]:
151154
"""
152155
Gets all unique sets (that is, either standalone fields that are marked `unique=True`, or
@@ -174,7 +177,7 @@ def get_uniques_without_foreign_keys(self) -> list[frozenset[str]]:
174177
return out
175178

176179

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

180183

@@ -214,7 +217,7 @@ def default(self, obj):
214217
return super().default(obj)
215218

216219

217-
class ImportKind(Enum):
220+
class ImportKind(str, Enum):
218221
"""
219222
When importing a given model, we may create a new copy of it (`Inserted`), merely re-use an
220223
`Existing` copy that has the same already-used globally unique identifier (ex: `username` for
@@ -224,9 +227,9 @@ class ImportKind(Enum):
224227
if they are dealing with a new or re-used model.
225228
"""
226229

227-
Inserted = auto()
228-
Existing = auto()
229-
Overwrite = auto()
230+
Inserted = "Inserted"
231+
Existing = "Existing"
232+
Overwrite = "Overwrite"
230233

231234

232235
class PrimaryKeyMap:
@@ -241,8 +244,7 @@ class PrimaryKeyMap:
241244
keys are not supported!
242245
"""
243246

244-
# Pydantic duplicates global default models on a per-instance basis, so using `{}` here is safe.
245-
mapping: Dict[str, Dict[int, Tuple[int, ImportKind]]]
247+
mapping: dict[str, dict[int, Tuple[int, ImportKind]]]
246248

247249
def __init__(self):
248250
self.mapping = defaultdict(dict)
@@ -262,7 +264,7 @@ def get_pk(self, model_name: NormalizedModelName, old: int) -> Optional[int]:
262264

263265
return entry[0]
264266

265-
def get_pks(self, model_name: NormalizedModelName) -> Set[int]:
267+
def get_pks(self, model_name: NormalizedModelName) -> set[int]:
266268
"""
267269
Get a list of all of the pks for a specific model.
268270
"""
@@ -291,6 +293,31 @@ def insert(self, model_name: NormalizedModelName, old: int, new: int, kind: Impo
291293

292294
self.mapping[str(model_name)][old] = (new, kind)
293295

296+
def extend(self, other: PrimaryKeyMap) -> None:
297+
"""
298+
Insert all values from another map into this one, without mutating the original map.
299+
"""
300+
301+
for model_name_str, mappings in other.mapping.items():
302+
for old_pk, new_entry in mappings.items():
303+
self.mapping[model_name_str][old_pk] = new_entry
304+
305+
def partition(self, model_names: set[NormalizedModelName]) -> PrimaryKeyMap:
306+
"""
307+
Create a new map with only the specified model kinds retained.
308+
"""
309+
310+
building = PrimaryKeyMap()
311+
for model_name_str, mappings in self.mapping.items():
312+
model_name = NormalizedModelName(model_name_str)
313+
if model_name not in model_names:
314+
continue
315+
316+
for old_pk, new_entry in mappings.items():
317+
building.mapping[model_name_str][old_pk] = new_entry
318+
319+
return building
320+
294321

295322
# No arguments, so we lazily cache the result after the first calculation.
296323
@lru_cache(maxsize=1)
@@ -312,7 +339,7 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
312339
from sentry.models.team import Team
313340

314341
# Process the list of models, and get the list of dependencies.
315-
model_dependencies_dict: Dict[NormalizedModelName, ModelRelations] = {}
342+
model_dependencies_dict: dict[NormalizedModelName, ModelRelations] = {}
316343
app_configs = apps.get_app_configs()
317344
models_from_names = {
318345
get_model_name(model): model
@@ -327,8 +354,8 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
327354
model_iterator = app_config.get_models()
328355

329356
for model in model_iterator:
330-
foreign_keys: Dict[str, ForeignField] = dict()
331-
uniques: Set[FrozenSet[str]] = {
357+
foreign_keys: dict[str, ForeignField] = dict()
358+
uniques: set[frozenset[str]] = {
332359
frozenset(combo) for combo in model._meta.unique_together
333360
}
334361

@@ -461,7 +488,7 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
461488
# models non-dangling, then traversing from every other model to a (possible) root model
462489
# recursively. At this point there should be no circular reference chains, so if we encounter
463490
# them, fail immediately.
464-
def resolve_dangling(seen: Set[NormalizedModelName], model_name: NormalizedModelName) -> bool:
491+
def resolve_dangling(seen: set[NormalizedModelName], model_name: NormalizedModelName) -> bool:
465492
model_relations = model_dependencies_dict[model_name]
466493
model_name = get_model_name(model_relations.model)
467494
if model_name in seen:
@@ -530,7 +557,7 @@ def sorted_dependencies() -> list[Type[models.base.Model]]:
530557
changed = False
531558
while model_dependencies_dict:
532559
model_deps = model_dependencies_dict.pop()
533-
deps = model_deps.flatten().union(model_deps.relocation_dependencies)
560+
deps = model_deps.get_dependencies_for_relocation()
534561
model = model_deps.model
535562

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

0 commit comments

Comments
 (0)