Skip to content

Commit aa79c45

Browse files
committed
feat(backup): Enable imports over RPC
This finishes out the work started in #57740 and enables imports over RPC as well. Like that PR, imports are already done on a sequential, per model basis, so this change just consists of moving every such call across an RPC boundary. Closes getsentry/team-ospo#196
1 parent f87f84f commit aa79c45

File tree

13 files changed

+1186
-671
lines changed

13 files changed

+1186
-671
lines changed

src/sentry/backup/imports.py

Lines changed: 69 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,47 @@
55
import click
66
from django.conf import settings
77
from django.core import serializers
8-
from django.core.exceptions import ValidationError as DjangoValidationError
9-
from django.db import IntegrityError, connections, router, transaction
8+
from django.db import transaction
109
from django.db.models.base import Model
11-
from rest_framework.serializers import ValidationError as DjangoRestFrameworkValidationError
1210

13-
from sentry.backup.dependencies import NormalizedModelName, PrimaryKeyMap, get_model, get_model_name
14-
from sentry.backup.helpers import EXCLUDED_APPS, Filter, ImportFlags
11+
from sentry.backup.dependencies import (
12+
NormalizedModelName,
13+
PrimaryKeyMap,
14+
dependencies,
15+
get_model_name,
16+
)
17+
from sentry.backup.helpers import Filter, ImportFlags
1518
from sentry.backup.scopes import ImportScope
16-
from sentry.silo import unguarded_write
19+
from sentry.services.hybrid_cloud.import_export.model import (
20+
RpcFilter,
21+
RpcImportError,
22+
RpcImportErrorKind,
23+
RpcImportFlags,
24+
RpcImportScope,
25+
RpcPrimaryKeyMap,
26+
)
27+
from sentry.services.hybrid_cloud.import_export.service import (
28+
ImportExportService,
29+
import_export_service,
30+
)
31+
from sentry.silo.base import SiloMode
32+
from sentry.silo.safety import unguarded_write
1733
from sentry.utils import json
1834

1935
__all__ = (
36+
"ImportingError",
2037
"import_in_user_scope",
2138
"import_in_organization_scope",
2239
"import_in_config_scope",
2340
"import_in_global_scope",
2441
)
2542

2643

44+
class ImportingError(Exception):
45+
def __init__(self, context: RpcImportError) -> None:
46+
self.context = context
47+
48+
2749
def _import(
2850
src,
2951
scope: ImportScope,
@@ -44,6 +66,11 @@ def _import(
4466
from sentry.models.organizationmember import OrganizationMember
4567
from sentry.models.user import User
4668

69+
if SiloMode.get_current_mode() == SiloMode.CONTROL:
70+
errText = "Imports must be run in REGION or MONOLITH instances only"
71+
printer(errText, err=True)
72+
raise RuntimeError(errText)
73+
4774
flags = flags if flags is not None else ImportFlags()
4875
user_model_name = get_model_name(User)
4976
org_model_name = get_model_name(Organization)
@@ -144,86 +171,48 @@ def yield_json_models(src) -> Iterator[Tuple[NormalizedModelName, str]]:
144171
if last_seen_model_name is not None and batch:
145172
yield (last_seen_model_name, json.dumps(batch))
146173

174+
def get_importer_for_model(model: Type[Model]):
175+
if SiloMode.CONTROL in model._meta.silo_limit.modes: # type: ignore
176+
return import_export_service.import_by_model
177+
return ImportExportService.get_local_implementation().import_by_model # type: ignore
178+
147179
# Extract some write logic into its own internal function, so that we may call it irrespective
148180
# of how we do atomicity: on a per-model (if using multiple dbs) or global (if using a single
149181
# db) basis.
150182
def do_write():
151-
allowed_relocation_scopes = scope.value
152183
pk_map = PrimaryKeyMap()
153-
for (batch_model_name, batch) in yield_json_models(src):
154-
model = get_model(batch_model_name)
155-
if model is None:
156-
raise ValueError("Unknown model name")
157-
158-
using = router.db_for_write(model)
159-
with transaction.atomic(using=using):
160-
count = 0
161-
for obj in serializers.deserialize("json", batch, use_natural_keys=False):
162-
o = obj.object
163-
if o._meta.app_label not in EXCLUDED_APPS or o:
164-
if o.get_possible_relocation_scopes() & allowed_relocation_scopes:
165-
o = obj.object
166-
model_name = get_model_name(o)
167-
for f in filters:
168-
if f.model == type(o) and getattr(o, f.field, None) not in f.values:
169-
break
170-
else:
171-
# We can only be sure `get_relocation_scope()` will be correct if it
172-
# is fired AFTER normalization, as some `get_relocation_scope()`
173-
# methods rely on being able to correctly resolve foreign keys,
174-
# which is only possible after normalization.
175-
old_pk = o.normalize_before_relocation_import(pk_map, scope, flags)
176-
if old_pk is None:
177-
continue
178-
179-
# Now that the model has been normalized, we can ensure that this
180-
# particular instance has a `RelocationScope` that permits
181-
# importing.
182-
if not o.get_relocation_scope() in allowed_relocation_scopes:
183-
continue
184-
185-
written = o.write_relocation_import(scope, flags)
186-
if written is None:
187-
continue
188-
189-
new_pk, import_kind = written
190-
pk_map.insert(model_name, old_pk, new_pk, import_kind)
191-
count += 1
192-
193-
# If we wrote at least one model, make sure to update the sequences too.
194-
if count > 0:
195-
table = o._meta.db_table
196-
seq = f"{table}_id_seq"
197-
with connections[using].cursor() as cursor:
198-
cursor.execute(f"SELECT setval(%s, (SELECT MAX(id) FROM {table}))", [seq])
199-
200-
try:
201-
if len(settings.DATABASES) == 1:
202-
# TODO(getsentry/team-ospo#185): This is currently untested in single-db mode. Fix ASAP!
203-
with unguarded_write(using="default"), transaction.atomic("default"):
204-
do_write()
205-
else:
184+
for model_name, json_data in yield_json_models(src):
185+
model_relations = dependencies().get(model_name)
186+
if not model_relations:
187+
continue
188+
189+
dep_models = {
190+
get_model_name(d) for d in model_relations.get_dependencies_for_relocation()
191+
}
192+
import_by_model = get_importer_for_model(model_relations.model)
193+
result = import_by_model(
194+
model_name=str(model_name),
195+
scope=RpcImportScope.into_rpc(scope),
196+
flags=RpcImportFlags.into_rpc(flags),
197+
filter_by=[RpcFilter.into_rpc(f) for f in filters],
198+
pk_map=RpcPrimaryKeyMap.into_rpc(pk_map.partition(dep_models)),
199+
json_data=json_data,
200+
)
201+
202+
if isinstance(result, RpcImportError):
203+
printer(result.pretty(), err=True)
204+
if result.get_kind() == RpcImportErrorKind.IntegrityError:
205+
warningText = ">> Are you restoring from a backup of the same version of Sentry?\n>> Are you restoring onto a clean database?\n>> If so then this IntegrityError might be our fault, you can open an issue here:\n>> https://github.com/getsentry/sentry/issues/new/choose"
206+
printer(warningText, err=True)
207+
raise ImportingError(result)
208+
pk_map.extend(result.mapped_pks)
209+
210+
if len(settings.DATABASES) == 1:
211+
# TODO(getsentry/team-ospo#185): This is currently untested in single-db mode. Fix ASAP!
212+
with unguarded_write(using="default"), transaction.atomic(using="default"):
206213
do_write()
207-
208-
# For all database integrity errors, let's warn users to follow our
209-
# recommended backup/restore workflow before reraising exception. Most of
210-
# these errors come from restoring on a different version of Sentry or not restoring
211-
# on a clean install.
212-
except IntegrityError as e:
213-
warningText = ">> Are you restoring from a backup of the same version of Sentry?\n>> Are you restoring onto a clean database?\n>> If so then this IntegrityError might be our fault, you can open an issue here:\n>> https://github.com/getsentry/sentry/issues/new/choose"
214-
printer(
215-
warningText,
216-
err=True,
217-
)
218-
raise (e)
219-
220-
# Calls to `write_relocation_import` may fail validation and throw either a
221-
# `DjangoValidationError` when a call to `.full_clean()` failed, or a
222-
# `DjangoRestFrameworkValidationError` when a call to a custom DRF serializer failed. This
223-
# exception catcher converts instances of the former to the latter.
224-
except DjangoValidationError as e:
225-
errs = {field: error for field, error in e.message_dict.items()}
226-
raise DjangoRestFrameworkValidationError(errs) from e
214+
else:
215+
do_write()
227216

228217

229218
def import_in_user_scope(

src/sentry/runner/commands/backup.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,6 @@
55
from sentry.backup.comparators import get_default_comparators
66
from sentry.backup.findings import FindingJSONEncoder
77
from sentry.backup.helpers import ImportFlags
8-
from sentry.backup.imports import (
9-
import_in_config_scope,
10-
import_in_global_scope,
11-
import_in_organization_scope,
12-
import_in_user_scope,
13-
)
148
from sentry.backup.validate import validate
159
from sentry.runner.decorators import configuration
1610
from sentry.utils import json
@@ -115,6 +109,8 @@ def import_users(src, filter_usernames, merge_users, silent):
115109
Import the Sentry users from an exported JSON file.
116110
"""
117111

112+
from sentry.backup.imports import import_in_user_scope
113+
118114
import_in_user_scope(
119115
src,
120116
flags=ImportFlags(merge_users=merge_users),
@@ -146,6 +142,8 @@ def import_organizations(src, filter_org_slugs, merge_users, silent):
146142
Import the Sentry organizations, and all constituent Sentry users, from an exported JSON file.
147143
"""
148144

145+
from sentry.backup.imports import import_in_organization_scope
146+
149147
import_in_organization_scope(
150148
src,
151149
flags=ImportFlags(merge_users=merge_users),
@@ -175,6 +173,8 @@ def import_config(src, merge_users, overwrite_configs, silent):
175173
Import all configuration and administrator accounts needed to set up this Sentry instance.
176174
"""
177175

176+
from sentry.backup.imports import import_in_config_scope
177+
178178
import_in_config_scope(
179179
src,
180180
flags=ImportFlags(merge_users=merge_users, overwrite_configs=overwrite_configs),
@@ -197,6 +197,8 @@ def import_global(src, silent, overwrite_configs):
197197
Import all Sentry data from an exported JSON file.
198198
"""
199199

200+
from sentry.backup.imports import import_in_global_scope
201+
200202
import_in_global_scope(
201203
src,
202204
flags=ImportFlags(overwrite_configs=overwrite_configs),

0 commit comments

Comments
 (0)