Browse Source

ref(hybrid-cloud): Region/Control Outboxes and Tombstones (#42687)

Implements cross silo replication primitives in the form of Outbox and Tombstone models.
Zach Collins 2 years ago
parent
commit
9af57a33c1

+ 1 - 1
migrations_lockfile.txt

@@ -6,5 +6,5 @@ To resolve this, rebase against latest master and regenerate your migration. Thi
 will then be regenerated, and you should be able to merge without conflicts.
 
 nodestore: 0002_nodestore_no_dictfield
-sentry: 0347_add_project_has_minified_stack_trace_flag
+sentry: 0348_add_outbox_and_tombstone_tables
 social_auth: 0001_initial

+ 0 - 1
src/sentry/api/serializers/models/__init__.py

@@ -6,7 +6,6 @@ from .alert_rule_trigger_action import *  # noqa: F401,F403
 from .apiapplication import *  # noqa: F401,F403
 from .apiauthorization import *  # noqa: F401,F403
 from .apikey import *  # noqa: F401,F403
-from .apiorganizationmapping import *  # noqa: F401,F403
 from .apitoken import *  # noqa: F401,F403
 from .app_platform_event import *  # noqa: F401,F403
 from .auditlogentry import *  # noqa: F401,F403

+ 0 - 19
src/sentry/api/serializers/models/apiorganizationmapping.py

@@ -1,19 +0,0 @@
-from typing import Any, Mapping
-
-from sentry.api.serializers import Serializer, register
-from sentry.models.user import User
-from sentry.services.hybrid_cloud.organization_mapping import APIOrganizationMapping
-
-
-@register(APIOrganizationMapping)
-class APIOrganizationMappingSerializer(Serializer):  # type: ignore
-    def serialize(self, obj: APIOrganizationMapping, attrs: Mapping[str, Any], user: User):
-        return {
-            "id": obj.id,
-            "organizationId": str(obj.organization_id),
-            "slug": obj.slug,
-            "regionName": obj.region_name,
-            "dateCreated": obj.date_created,
-            "verified": obj.verified,
-            "customerId": obj.customer_id,
-        }

+ 6 - 1
src/sentry/conf/server.py

@@ -574,6 +574,7 @@ CELERY_IMPORTS = (
     "sentry.tasks.commits",
     "sentry.tasks.commit_context",
     "sentry.tasks.deletion",
+    "sentry.tasks.deliver_from_outbox",
     "sentry.tasks.digests",
     "sentry.tasks.email",
     "sentry.tasks.files",
@@ -753,6 +754,11 @@ CELERYBEAT_SCHEDULE = {
         "schedule": crontab_with_minute_jitter(hour=3),
         "options": {"expires": 3600 * 24},
     },
+    "deliver-from-outbox": {
+        "task": "sentry.tasks.enqueue_outbox_jobs",
+        "schedule": timedelta(minutes=1),
+        "options": {"expires": 30},
+    },
     "update-user-reports": {
         "task": "sentry.tasks.update_user_reports",
         "schedule": timedelta(minutes=15),
@@ -2879,7 +2885,6 @@ SENTRY_FUNCTIONS_REGION = "us-central1"
 # Settings related to SiloMode
 SILO_MODE = os.environ.get("SENTRY_SILO_MODE", None)
 FAIL_ON_UNAVAILABLE_API_CALL = False
-SILO_MODE_UNSTABLE_TESTS = bool(os.environ.get("SENTRY_SILO_MODE_UNSTABLE_TESTS", False))
 
 DISALLOWED_CUSTOMER_DOMAINS = []
 

+ 131 - 0
src/sentry/migrations/0348_add_outbox_and_tombstone_tables.py

@@ -0,0 +1,131 @@
+# Generated by Django 2.2.28 on 2022-12-29 21:05
+
+import datetime
+
+import django.utils.timezone
+from django.db import migrations, models
+from django.utils.timezone import utc
+
+import sentry.db.models.fields.bounded
+import sentry.db.models.fields.jsonfield
+from sentry.new_migrations.migrations import CheckedMigration
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production. For
+    # the most part, this should only be used for operations where it's safe to run the migration
+    # after your code has deployed. So this should not be used for most operations that alter the
+    # schema of a table.
+    # Here are some things that make sense to mark as dangerous:
+    # - Large data migrations. Typically we want these to be run manually by ops so that they can
+    #   be monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   have ops run this and not block the deploy. Note that while adding an index is a schema
+    #   change, it's completely safe to run the operation after the code has deployed.
+    is_dangerous = False
+
+    dependencies = [
+        ("sentry", "0347_add_project_has_minified_stack_trace_flag"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ControlTombstone",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("table_name", models.CharField(max_length=48)),
+                ("object_identifier", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
+            ],
+            options={
+                "db_table": "sentry_controltombstone",
+            },
+        ),
+        migrations.CreateModel(
+            name="RegionTombstone",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("table_name", models.CharField(max_length=48)),
+                ("object_identifier", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
+            ],
+            options={
+                "db_table": "sentry_regiontombstone",
+            },
+        ),
+        migrations.CreateModel(
+            name="RegionOutbox",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("shard_scope", sentry.db.models.fields.bounded.BoundedPositiveIntegerField()),
+                ("shard_identifier", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("category", sentry.db.models.fields.bounded.BoundedPositiveIntegerField()),
+                ("object_identifier", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("payload", sentry.db.models.fields.jsonfield.JSONField(null=True)),
+                ("scheduled_from", models.DateTimeField(default=django.utils.timezone.now)),
+                (
+                    "scheduled_for",
+                    models.DateTimeField(default=datetime.datetime(2016, 8, 1, 0, 0, tzinfo=utc)),
+                ),
+            ],
+            options={
+                "db_table": "sentry_regionoutbox",
+                "index_together": {
+                    ("shard_scope", "shard_identifier", "id"),
+                    ("shard_scope", "shard_identifier", "scheduled_for"),
+                    ("shard_scope", "shard_identifier", "category", "object_identifier"),
+                },
+            },
+        ),
+        migrations.CreateModel(
+            name="ControlOutbox",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("shard_scope", sentry.db.models.fields.bounded.BoundedPositiveIntegerField()),
+                ("shard_identifier", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("category", sentry.db.models.fields.bounded.BoundedPositiveIntegerField()),
+                ("object_identifier", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("payload", sentry.db.models.fields.jsonfield.JSONField(null=True)),
+                ("scheduled_from", models.DateTimeField(default=django.utils.timezone.now)),
+                (
+                    "scheduled_for",
+                    models.DateTimeField(default=datetime.datetime(2016, 8, 1, 0, 0, tzinfo=utc)),
+                ),
+                ("region_name", models.CharField(max_length=48)),
+            ],
+            options={
+                "db_table": "sentry_controloutbox",
+                "index_together": {
+                    ("region_name", "shard_scope", "shard_identifier", "id"),
+                    ("region_name", "shard_scope", "shard_identifier", "scheduled_for"),
+                    (
+                        "region_name",
+                        "shard_scope",
+                        "shard_identifier",
+                        "category",
+                        "object_identifier",
+                    ),
+                },
+            },
+        ),
+    ]

+ 2 - 0
src/sentry/models/__init__.py

@@ -72,6 +72,7 @@ from .organizationmapping import *  # NOQA
 from .organizationmember import *  # NOQA
 from .organizationmemberteam import *  # NOQA
 from .organizationonboardingtask import *  # NOQA
+from .outbox import *  # NOQA
 from .platformexternalissue import *  # NOQA
 from .processingissue import *  # NOQA
 from .project import *  # NOQA
@@ -105,6 +106,7 @@ from .search_common import *  # NOQA
 from .sentryfunction import *  # NOQA
 from .servicehook import *  # NOQA
 from .team import *  # NOQA
+from .tombstone import *  # NOQA
 from .transaction_threshold import *  # NOQA
 from .user import *  # NOQA
 from .useremail import *  # NOQA

+ 18 - 3
src/sentry/models/organization.py

@@ -31,11 +31,12 @@ from sentry.db.models import (
 from sentry.db.models.utils import slugify_instance
 from sentry.locks import locks
 from sentry.models.organizationmember import OrganizationMember
+from sentry.models.outbox import OutboxCategory, OutboxScope, RegionOutbox
 from sentry.roles.manager import Role
 from sentry.services.hybrid_cloud.user import APIUser, user_service
 from sentry.utils.http import is_using_customer_domain
 from sentry.utils.retries import TimedRetryPolicy
-from sentry.utils.snowflake import SnowflakeIdMixin
+from sentry.utils.snowflake import SnowflakeIdMixin, generate_snowflake_id
 
 SENTRY_USE_SNOWFLAKE = getattr(settings, "SENTRY_USE_SNOWFLAKE", False)
 
@@ -195,6 +196,8 @@ class Organization(Model, SnowflakeIdMixin):
     def __str__(self):
         return f"{self.name} ({self.slug})"
 
+    snowflake_redis_key = "organization_snowflake_key"
+
     def save(self, *args, **kwargs):
         slugify_target = None
         if not self.slug:
@@ -208,13 +211,16 @@ class Organization(Model, SnowflakeIdMixin):
                 slugify_instance(self, slugify_target, reserved=RESERVED_ORGANIZATION_SLUGS)
 
         if SENTRY_USE_SNOWFLAKE:
-            snowflake_redis_key = "organization_snowflake_key"
             self.save_with_snowflake_id(
-                snowflake_redis_key, lambda: super(Organization, self).save(*args, **kwargs)
+                self.snowflake_redis_key, lambda: super(Organization, self).save(*args, **kwargs)
             )
         else:
             super().save(*args, **kwargs)
 
+    @classmethod
+    def reserve_snowflake_id(cls):
+        return generate_snowflake_id(cls.snowflake_redis_key)
+
     def delete(self, **kwargs):
         from sentry.models import NotificationSetting
 
@@ -226,6 +232,15 @@ class Organization(Model, SnowflakeIdMixin):
 
         return super().delete(**kwargs)
 
+    @staticmethod
+    def outbox_for_update(org_id: int) -> RegionOutbox:
+        return RegionOutbox(
+            shard_scope=OutboxScope.ORGANIZATION_SCOPE,
+            shard_identifier=org_id,
+            category=OutboxCategory.ORGANIZATION_UPDATE,
+            object_identifier=org_id,
+        )
+
     @cached_property
     def is_default(self):
         if not settings.SENTRY_SINGLE_ORGANIZATION:

+ 10 - 0
src/sentry/models/organizationmember.py

@@ -28,6 +28,7 @@ from sentry.db.models import (
 )
 from sentry.db.models.manager import BaseManager
 from sentry.exceptions import UnableToAcceptMemberInvitationException
+from sentry.models.outbox import OutboxCategory, OutboxScope, RegionOutbox
 from sentry.models.team import TeamStatus
 from sentry.roles import organization_roles
 from sentry.signals import member_invited
@@ -190,6 +191,15 @@ class OrganizationMember(Model):
         self.token = self.generate_token()
         self.refresh_expires_at()
 
+    @staticmethod
+    def outbox_for_update(org_id: int, org_member_id: int) -> RegionOutbox:
+        return RegionOutbox(
+            shard_scope=OutboxScope.ORGANIZATION_SCOPE,
+            shard_identifier=org_id,
+            category=OutboxCategory.ORGANIZATION_MEMBER_UPDATE,
+            object_identifier=org_member_id,
+        )
+
     def refresh_expires_at(self):
         now = timezone.now()
         self.token_expires_at = now + timedelta(days=INVITE_DAYS_VALID)

+ 336 - 0
src/sentry/models/outbox.py

@@ -0,0 +1,336 @@
+from __future__ import annotations
+
+import abc
+import contextlib
+import datetime
+import sys
+from enum import IntEnum
+from typing import Any, Generator, Iterable, List, Mapping, Set
+
+from django.db import connections, models, router, transaction
+from django.db.models import Max
+from django.dispatch import Signal
+from django.utils import timezone
+
+from sentry.db.models import (
+    BoundedBigIntegerField,
+    BoundedPositiveIntegerField,
+    JSONField,
+    Model,
+    control_silo_only_model,
+    region_silo_only_model,
+    sane_repr,
+)
+from sentry.silo import SiloMode
+
+THE_PAST = datetime.datetime(2016, 8, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
+
+
+class OutboxScope(IntEnum):
+    ORGANIZATION_SCOPE = 0
+    USER_SCOPE = 1
+    WEBHOOK_SCOPE = 2
+
+    def __str__(self):
+        return self.name
+
+    @classmethod
+    def as_choices(cls):
+        return [(i.value, i.value) for i in cls]
+
+
+class OutboxCategory(IntEnum):
+    USER_UPDATE = 0
+    WEBHOOK_PROXY = 1
+    ORGANIZATION_UPDATE = 2
+    ORGANIZATION_MEMBER_UPDATE = 3
+
+    @classmethod
+    def as_choices(cls):
+        return [(i.value, i.value) for i in cls]
+
+
+class WebhookProviderIdentifier(IntEnum):
+    SLACK = 0
+    GITHUB = 1
+
+
+def _ensure_not_null(k: str, v: Any) -> Any:
+    if v is None:
+        raise ValueError(f"Attribute {k} was None, but it needed to be set!")
+    return v
+
+
+class OutboxFlushError(Exception):
+    pass
+
+
+class OutboxBase(Model):
+    sharding_columns: Iterable[str]
+    coalesced_columns: Iterable[str]
+
+    @classmethod
+    def _unique_object_identifier(cls):
+        using = router.db_for_write(cls)
+        with transaction.atomic(using=using):
+            with connections[using].cursor() as cursor:
+                cursor.execute("SELECT nextval(%s)", [f"{cls._meta.db_table}_id_seq"])
+                return cursor.fetchone()[0]
+
+    @classmethod
+    def find_scheduled_shards(cls) -> Iterable[Mapping[str, Any]]:
+        return (
+            cls.objects.values(*cls.sharding_columns)
+            .annotate(
+                scheduled_for=Max("scheduled_for"),
+                id=Max("id"),
+            )
+            .filter(scheduled_for__lte=timezone.now())
+            .order_by("scheduled_for", "id")
+        )
+
+    @classmethod
+    def prepare_next_from_shard(cls, row: Mapping[str, Any]) -> OutboxBase | None:
+        with transaction.atomic(savepoint=False):
+            next_outbox: OutboxBase | None
+            next_outbox = (
+                cls(**row).selected_messages_in_shard().order_by("id").select_for_update().first()
+            )
+            if not next_outbox:
+                return None
+
+            # We rely on 'proof of failure by remaining' to handle retries -- basically, by scheduling this shard, we
+            # expect all objects to be drained before the next schedule comes around, or else we will run again.
+            # Note that the system does not strongly protect against concurrent processing -- this is expected in the
+            # case of drains, for instance.
+            now = timezone.now()
+            next_outbox.selected_messages_in_shard().update(
+                scheduled_for=next_outbox.next_schedule(now), scheduled_from=now
+            )
+
+            return next_outbox
+
+    def key_from(self, attrs: Iterable[str]) -> Mapping[str, Any]:
+        return {k: _ensure_not_null(k, getattr(self, k)) for k in attrs}
+
+    def selected_messages_in_shard(self) -> models.QuerySet:
+        return self.objects.filter(**self.key_from(self.sharding_columns))
+
+    def select_coalesced_messages(self) -> models.QuerySet:
+        return self.objects.filter(**self.key_from(self.coalesced_columns))
+
+    class Meta:
+        abstract = True
+
+    __include_in_export__ = False
+
+    # Different shard_scope, shard_identifier pairings of messages are always deliverable in parallel
+    shard_scope = BoundedPositiveIntegerField(choices=OutboxScope.as_choices(), null=False)
+    shard_identifier = BoundedBigIntegerField(null=False)
+
+    # Objects of equal scope, shard_identifier, category, and object_identifier are coalesced in processing.
+    category = BoundedPositiveIntegerField(choices=OutboxCategory.as_choices(), null=False)
+    object_identifier = BoundedBigIntegerField(null=False)
+
+    # payload is used for webhook payloads.
+    payload = JSONField(null=True)
+
+    # The point at which this object was scheduled, used as a diff from scheduled_for to determine the intended delay.
+    scheduled_from = models.DateTimeField(null=False, default=timezone.now)
+    # The point at which this object is intended to be replicated, used for backoff purposes.  Keep in mind that
+    # the largest back off effectively applies to the entire 'shard' key.
+    scheduled_for = models.DateTimeField(null=False, default=THE_PAST)
+
+    def last_delay(self) -> datetime.timedelta:
+        return max(self.scheduled_for - self.scheduled_from, datetime.timedelta(seconds=1))
+
+    def next_schedule(self, now: datetime.datetime) -> datetime.datetime:
+        return now + (self.last_delay() * 2)
+
+    @contextlib.contextmanager
+    def process_coalesced(self) -> Generator[OutboxBase | None, None, None]:
+        # Do not, use a select for update here -- it is tempting, but a major performance issue.
+        # we should simply accept the occasional multiple sends than to introduce hard locking.
+        # so long as all objects sent are committed, and so long as any concurrent changes to data
+        # result in a future processing, we should always converge on non stale values.
+        coalesced: OutboxBase | None = self.select_coalesced_messages().last()
+        yield coalesced
+        if coalesced is not None:
+            self.select_coalesced_messages().filter(id__lte=coalesced.id).delete()
+
+    def process(self) -> bool:
+        with self.process_coalesced() as coalesced:
+            if coalesced is not None:
+                coalesced.send_signal()
+                return True
+        return False
+
+    @abc.abstractmethod
+    def send_signal(self):
+        pass
+
+    def drain_shard(self, max_updates_to_drain: int | None = None):
+        runs = 0
+        next_row: OutboxBase | None = self.selected_messages_in_shard().first()
+        while next_row is not None and (
+            max_updates_to_drain is None or runs < max_updates_to_drain
+        ):
+            runs += 1
+            next_row.process()
+            next_row: OutboxBase | None = self.selected_messages_in_shard().first()
+
+        if next_row is not None:
+            raise OutboxFlushError(
+                f"Could not flush items from shard {self.key_from(self.sharding_columns)!r}"
+            )
+
+
+MONOLITH_REGION_NAME = "--monolith--"
+
+
+# Outboxes bound from region silo -> control silo
+@region_silo_only_model
+class RegionOutbox(OutboxBase):
+    def send_signal(self):
+        process_region_outbox.send(
+            sender=OutboxCategory(self.category),
+            payload=self.payload,
+            object_identifier=self.object_identifier,
+        )
+
+    sharding_columns = ("shard_scope", "shard_identifier")
+    coalesced_columns = ("shard_scope", "shard_identifier", "category", "object_identifier")
+
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_regionoutbox"
+        index_together = (
+            (
+                "shard_scope",
+                "shard_identifier",
+                "category",
+                "object_identifier",
+            ),
+            (
+                "shard_scope",
+                "shard_identifier",
+                "scheduled_for",
+            ),
+            ("shard_scope", "shard_identifier", "id"),
+        )
+
+    __repr__ = sane_repr("shard_scope", "shard_identifier", "category", "object_identifier")
+
+
+# Outboxes bound from region silo -> control silo
+@control_silo_only_model
+class ControlOutbox(OutboxBase):
+    sharding_columns = ("region_name", "shard_scope", "shard_identifier")
+    coalesced_columns = (
+        "region_name",
+        "shard_scope",
+        "shard_identifier",
+        "category",
+        "object_identifier",
+    )
+
+    region_name = models.CharField(max_length=48)
+
+    def send_signal(self):
+        process_control_outbox.send(
+            sender=self.category,
+            payload=self.payload,
+            region_name=self.region_name,
+            object_identifier=self.object_identifier,
+        )
+
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_controloutbox"
+        index_together = (
+            (
+                "region_name",
+                "shard_scope",
+                "shard_identifier",
+                "category",
+                "object_identifier",
+            ),
+            (
+                "region_name",
+                "shard_scope",
+                "shard_identifier",
+                "scheduled_for",
+            ),
+            ("region_name", "shard_scope", "shard_identifier", "id"),
+        )
+
+    __repr__ = sane_repr(
+        "region_name", "shard_scope", "shard_identifier", "category", "object_identifier"
+    )
+
+    @classmethod
+    def for_webhook_update(
+        cls,
+        *,
+        webhook_identifier: WebhookProviderIdentifier,
+        region_names: List[str],
+        payload=Mapping[str, Any],
+    ) -> Iterable[ControlOutbox]:
+        for region_name in region_names:
+            result = cls()
+            result.shard_scope = OutboxScope.WEBHOOK_SCOPE
+            result.shard_identifier = webhook_identifier.value
+            result.object_identifier = cls._unique_object_identifier()
+            result.category = OutboxCategory.WEBHOOK_PROXY
+            result.region_name = region_name
+            result.payload = payload
+            yield result
+
+
+def _find_orgs_for_user(user_id: int) -> Set[int]:
+    # TODO: This must be changed to the org member mapping in the control silo eventually.
+    from sentry.models import OrganizationMember
+
+    return {
+        m["organization_id"]
+        for m in OrganizationMember.objects.filter(user_id=user_id).values("organization_id")
+    }
+
+
+def find_regions_for_user(user_id: int) -> Set[str]:
+    from sentry.models import OrganizationMapping
+
+    org_ids: Set[int]
+    if "pytest" in sys.modules:
+        from sentry.testutils.silo import exempt_from_silo_limits
+
+        with exempt_from_silo_limits():
+            org_ids = _find_orgs_for_user(user_id)
+    else:
+        org_ids = _find_orgs_for_user(user_id)
+
+    if SiloMode.get_current_mode() == SiloMode.MONOLITH:
+        return {
+            MONOLITH_REGION_NAME,
+        }
+    else:
+        return {
+            t["region_name"]
+            for t in OrganizationMapping.objects.filter(organization_id__in=org_ids).values(
+                "region_name"
+            )
+        }
+
+
+def outbox_silo_modes() -> List[SiloMode]:
+    cur = SiloMode.get_current_mode()
+    result: List[SiloMode] = []
+    if cur != SiloMode.REGION:
+        result.append(SiloMode.CONTROL)
+    if cur != SiloMode.CONTROL:
+        result.append(SiloMode.REGION)
+    return result
+
+
+process_region_outbox = Signal(providing_args=["payload", "object_identifier"])
+process_control_outbox = Signal(providing_args=["payload", "region_name", "object_identifier"])

+ 43 - 0
src/sentry/models/tombstone.py

@@ -0,0 +1,43 @@
+from django.db import IntegrityError, models, transaction
+from django.utils import timezone
+
+from sentry.db.models import (
+    BoundedBigIntegerField,
+    Model,
+    control_silo_only_model,
+    region_silo_only_model,
+)
+
+
+class TombstoneBase(Model):
+    class Meta:
+        abstract = True
+        unique_together = ("table_name", "object_identifier")
+
+    __include_in_export__ = False
+
+    table_name = models.CharField(max_length=48, null=False)
+    object_identifier = BoundedBigIntegerField(null=False)
+    created_at = models.DateTimeField(null=False, default=timezone.now)
+
+    @classmethod
+    def record_delete(cls, table_name: str, identifier: int):
+        try:
+            with transaction.atomic():
+                cls.objects.create(table_name=table_name, object_identifier=identifier)
+        except IntegrityError:
+            pass
+
+
+@region_silo_only_model
+class RegionTombstone(TombstoneBase):
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_regiontombstone"
+
+
+@control_silo_only_model
+class ControlTombstone(TombstoneBase):
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_controltombstone"

Some files were not shown because too many files changed in this diff