Skip to content

Commit 00fb8dd

Browse files
authored
feat(backup): Enable imports over RPC (#58412)
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. This is a second attempt at landing this PR, with fewer tests tested in both modes.
1 parent 03d1596 commit 00fb8dd

File tree

22 files changed

+1305
-736
lines changed

22 files changed

+1305
-736
lines changed

fixtures/backup/model_dependencies/detailed.json

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5851,38 +5851,6 @@
58515851
"table_name": "sentry_userrole_users",
58525852
"uniques": []
58535853
},
5854-
"sessions.session": {
5855-
"dangling": false,
5856-
"foreign_keys": {},
5857-
"model": "sessions.session",
5858-
"relocation_dependencies": [],
5859-
"relocation_scope": "Excluded",
5860-
"silos": [
5861-
"Monolith"
5862-
],
5863-
"table_name": "django_session",
5864-
"uniques": [
5865-
[
5866-
"session_key"
5867-
]
5868-
]
5869-
},
5870-
"sites.site": {
5871-
"dangling": false,
5872-
"foreign_keys": {},
5873-
"model": "sites.site",
5874-
"relocation_dependencies": [],
5875-
"relocation_scope": "Excluded",
5876-
"silos": [
5877-
"Monolith"
5878-
],
5879-
"table_name": "django_site",
5880-
"uniques": [
5881-
[
5882-
"domain"
5883-
]
5884-
]
5885-
},
58865854
"social_auth.usersocialauth": {
58875855
"dangling": false,
58885856
"foreign_keys": {

fixtures/backup/model_dependencies/flat.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -807,8 +807,6 @@
807807
"sentry.user",
808808
"sentry.userrole"
809809
],
810-
"sessions.session": [],
811-
"sites.site": [],
812810
"social_auth.usersocialauth": [
813811
"sentry.user"
814812
]

fixtures/backup/model_dependencies/sorted.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@
4949
"sentry.userpermission",
5050
"sentry.userrole",
5151
"sentry.userroleuser",
52-
"sessions.session",
53-
"sites.site",
5452
"social_auth.usersocialauth",
5553
"sentry.savedsearch",
5654
"sentry.release",

fixtures/backup/model_dependencies/truncate.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@
4949
"sentry_userpermission",
5050
"sentry_userrole",
5151
"sentry_userrole_users",
52-
"django_session",
53-
"django_site",
5452
"social_auth_usersocialauth",
5553
"sentry_savedsearch",
5654
"sentry_release",

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,6 @@ module = [
603603
"sentry.services.smtp",
604604
"sentry.shared_integrations.client.base",
605605
"sentry.shared_integrations.client.proxy",
606-
"sentry.silo.base",
607606
"sentry.similarity.backends.dummy",
608607
"sentry.similarity.features",
609608
"sentry.snuba.discover",

src/sentry/backup/dependencies.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,11 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]:
379379
model_iterator = app_config.get_models()
380380

381381
for model in model_iterator:
382+
# Ignore some native Django models, since other models don't reference them and we don't
383+
# really use them for business logic.
384+
if model._meta.app_label in {"sessions", "sites"}:
385+
continue
386+
382387
foreign_keys: dict[str, ForeignField] = dict()
383388
uniques: set[frozenset[str]] = {
384389
frozenset(combo) for combo in model._meta.unique_together

src/sentry/backup/exports.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from __future__ import annotations
22

33
import io
4-
from typing import BinaryIO, Type
4+
from typing import BinaryIO
55

66
import click
7-
from django.db.models.base import Model
87

98
from sentry.backup.dependencies import (
109
PrimaryKeyMap,
@@ -93,11 +92,6 @@ def _export(
9392
else:
9493
raise ValueError("Filter arguments must only apply to `Organization` or `User` models")
9594

96-
def get_exporter_for_model(model: Type[Model]):
97-
if SiloMode.CONTROL in model._meta.silo_limit.modes: # type: ignore
98-
return import_export_service.export_by_model
99-
return ImportExportService.get_local_implementation().export_by_model # type: ignore
100-
10195
# TODO(getsentry/team-ospo#190): Another optimization opportunity to use a generator with ijson # to print the JSON objects in a streaming manner.
10296
for model in sorted_dependencies():
10397
from sentry.db.models.base import BaseModel
@@ -116,7 +110,7 @@ def get_exporter_for_model(model: Type[Model]):
116110
continue
117111

118112
dep_models = {get_model_name(d) for d in model_relations.get_dependencies_for_relocation()}
119-
export_by_model = get_exporter_for_model(model)
113+
export_by_model = ImportExportService.get_exporter_for_model(model)
120114
result = export_by_model(
121115
model_name=str(model_name),
122116
scope=RpcExportScope.into_rpc(scope),

src/sentry/backup/imports.py

Lines changed: 61 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,46 @@
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 ImportExportService
27+
from sentry.silo.base import SiloMode
28+
from sentry.silo.safety import unguarded_write
1729
from sentry.utils import json
30+
from sentry.utils.env import is_split_db
1831

1932
__all__ = (
33+
"ImportingError",
2034
"import_in_user_scope",
2135
"import_in_organization_scope",
2236
"import_in_config_scope",
2337
"import_in_global_scope",
2438
)
2539

2640

41+
class ImportingError(Exception):
42+
def __init__(self, context: RpcImportError) -> None:
43+
self.context = context
44+
45+
2746
def _import(
2847
src: BinaryIO,
2948
scope: ImportScope,
@@ -45,6 +64,11 @@ def _import(
4564
from sentry.models.organizationmember import OrganizationMember
4665
from sentry.models.user import User
4766

67+
if SiloMode.get_current_mode() == SiloMode.CONTROL:
68+
errText = "Imports must be run in REGION or MONOLITH instances only"
69+
printer(errText, err=True)
70+
raise RuntimeError(errText)
71+
4872
flags = flags if flags is not None else ImportFlags()
4973
user_model_name = get_model_name(User)
5074
org_model_name = get_model_name(Organization)
@@ -154,83 +178,38 @@ def yield_json_models(src) -> Iterator[Tuple[NormalizedModelName, str]]:
154178
# of how we do atomicity: on a per-model (if using multiple dbs) or global (if using a single
155179
# db) basis.
156180
def do_write():
157-
allowed_relocation_scopes = scope.value
158181
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:
182+
for model_name, json_data in yield_json_models(src):
183+
model_relations = dependencies().get(model_name)
184+
if not model_relations:
185+
continue
186+
187+
dep_models = {
188+
get_model_name(d) for d in model_relations.get_dependencies_for_relocation()
189+
}
190+
import_by_model = ImportExportService.get_importer_for_model(model_relations.model)
191+
result = import_by_model(
192+
model_name=str(model_name),
193+
scope=RpcImportScope.into_rpc(scope),
194+
flags=RpcImportFlags.into_rpc(flags),
195+
filter_by=[RpcFilter.into_rpc(f) for f in filters],
196+
pk_map=RpcPrimaryKeyMap.into_rpc(pk_map.partition(dep_models)),
197+
json_data=json_data,
198+
)
199+
200+
if isinstance(result, RpcImportError):
201+
printer(result.pretty(), err=True)
202+
if result.get_kind() == RpcImportErrorKind.IntegrityError:
203+
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"
204+
printer(warningText, err=True)
205+
raise ImportingError(result)
206+
pk_map.extend(result.mapped_pks)
207+
208+
if SiloMode.get_current_mode() == SiloMode.MONOLITH and not is_split_db():
209+
with unguarded_write(using="default"), transaction.atomic(using="default"):
213210
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
211+
else:
212+
do_write()
234213

235214

236215
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)