Skip to content

Commit bcfcd01

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#185 Closes getsentry/team-ospo#196
1 parent 6028542 commit bcfcd01

File tree

13 files changed

+1219
-680
lines changed

13 files changed

+1219
-680
lines changed

src/sentry/backup/imports.py

Lines changed: 69 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,49 @@
33
from typing import BinaryIO, Iterator, Optional, Tuple, Type
44

55
import click
6-
from django.conf import settings
76
from django.core import serializers
8-
from django.core.exceptions import ValidationError as DjangoValidationError
9-
from django.db import IntegrityError, connections, router, transaction
7+
from django.db import transaction
108
from django.db.models.base import Model
11-
from rest_framework.serializers import ValidationError as DjangoRestFrameworkValidationError
129

13-
from sentry.backup.dependencies import NormalizedModelName, PrimaryKeyMap, get_model, get_model_name
14-
from sentry.backup.helpers import EXCLUDED_APPS, Filter, ImportFlags, decrypt_encrypted_tarball
10+
from sentry.backup.dependencies import (
11+
NormalizedModelName,
12+
PrimaryKeyMap,
13+
dependencies,
14+
get_model_name,
15+
)
16+
from sentry.backup.helpers import Filter, ImportFlags, decrypt_encrypted_tarball
1517
from sentry.backup.scopes import ImportScope
16-
from sentry.silo import unguarded_write
18+
from sentry.services.hybrid_cloud.import_export.model import (
19+
RpcFilter,
20+
RpcImportError,
21+
RpcImportErrorKind,
22+
RpcImportFlags,
23+
RpcImportScope,
24+
RpcPrimaryKeyMap,
25+
)
26+
from sentry.services.hybrid_cloud.import_export.service import (
27+
ImportExportService,
28+
import_export_service,
29+
)
30+
from sentry.silo.base import SiloMode
31+
from sentry.silo.safety import unguarded_write
1732
from sentry.utils import json
33+
from sentry.utils.env import is_split_db
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: BinaryIO,
2951
scope: ImportScope,
@@ -45,6 +67,11 @@ def _import(
4567
from sentry.models.organizationmember import OrganizationMember
4668
from sentry.models.user import User
4769

70+
if SiloMode.get_current_mode() == SiloMode.CONTROL:
71+
errText = "Imports must be run in REGION or MONOLITH instances only"
72+
printer(errText, err=True)
73+
raise RuntimeError(errText)
74+
4875
flags = flags if flags is not None else ImportFlags()
4976
user_model_name = get_model_name(User)
5077
org_model_name = get_model_name(Organization)
@@ -150,87 +177,47 @@ def yield_json_models(src) -> Iterator[Tuple[NormalizedModelName, str]]:
150177
if last_seen_model_name is not None and batch:
151178
yield (last_seen_model_name, json.dumps(batch))
152179

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

235222

236223
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
@@ -136,6 +130,8 @@ def import_users(src, decrypt_with, filter_usernames, merge_users, silent):
136130
Import the Sentry users from an exported JSON file.
137131
"""
138132

133+
from sentry.backup.imports import import_in_user_scope
134+
139135
import_in_user_scope(
140136
src,
141137
decrypt_with=decrypt_with,
@@ -173,6 +169,8 @@ def import_organizations(src, decrypt_with, filter_org_slugs, merge_users, silen
173169
Import the Sentry organizations, and all constituent Sentry users, from an exported JSON file.
174170
"""
175171

172+
from sentry.backup.imports import import_in_organization_scope
173+
176174
import_in_organization_scope(
177175
src,
178176
decrypt_with=decrypt_with,
@@ -208,6 +206,8 @@ def import_config(src, decrypt_with, merge_users, overwrite_configs, silent):
208206
Import all configuration and administrator accounts needed to set up this Sentry instance.
209207
"""
210208

209+
from sentry.backup.imports import import_in_config_scope
210+
211211
import_in_config_scope(
212212
src,
213213
decrypt_with=decrypt_with,
@@ -236,6 +236,8 @@ def import_global(src, decrypt_with, silent, overwrite_configs):
236236
Import all Sentry data from an exported JSON file.
237237
"""
238238

239+
from sentry.backup.imports import import_in_global_scope
240+
239241
import_in_global_scope(
240242
src,
241243
decrypt_with=decrypt_with,

0 commit comments

Comments
 (0)