Browse Source

feat(hybrid-cloud): Adds OrganizationSlugReservation model (#56584)

Gabe Villalobos 1 year ago
parent
commit
07a745d13c

+ 17 - 0
fixtures/backup/model_dependencies/detailed.json

@@ -2181,6 +2181,23 @@
       "Region"
     ]
   },
+  "sentry.organizationslugreservation": {
+    "foreign_keys": {
+      "organization_id": {
+        "kind": "ImplicitForeignKey",
+        "model": "sentry.organization"
+      },
+      "user_id": {
+        "kind": "ImplicitForeignKey",
+        "model": "sentry.user"
+      }
+    },
+    "model": "sentry.organizationslugreservation",
+    "relocation_scope": "Excluded",
+    "silos": [
+      "Control"
+    ]
+  },
   "sentry.orgauthtoken": {
     "foreign_keys": {
       "created_by": {

+ 4 - 0
fixtures/backup/model_dependencies/flat.json

@@ -488,6 +488,10 @@
   "sentry.organizationoption": [
     "sentry.organization"
   ],
+  "sentry.organizationslugreservation": [
+    "sentry.organization",
+    "sentry.user"
+  ],
   "sentry.orgauthtoken": [
     "sentry.organization",
     "sentry.project",

+ 1 - 0
fixtures/backup/model_dependencies/sorted.json

@@ -21,6 +21,7 @@
   "sentry.integration",
   "sentry.integrationfeature",
   "sentry.user",
+  "sentry.organizationslugreservation",
   "sentry.project",
   "sentry.projectbookmark",
   "sentry.projectkey",

+ 1 - 1
migrations_lockfile.txt

@@ -8,5 +8,5 @@ will then be regenerated, and you should be able to merge without conflicts.
 feedback: 0002_feedback_add_org_id_and_rename_event_id
 nodestore: 0002_nodestore_no_dictfield
 replays: 0003_add_size_to_recording_segment
-sentry: 0566_remove_cron_missed_margins_zero
+sentry: 0567_add_slug_reservation_model
 social_auth: 0002_default_auto_field

+ 58 - 0
src/sentry/migrations/0567_add_slug_reservation_model.py

@@ -0,0 +1,58 @@
+# Generated by Django 3.2.20 on 2023-09-25 18:05
+
+import django.utils.timezone
+from django.db import migrations, models
+
+import sentry.db.models.fields.bounded
+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", "0566_remove_cron_missed_margins_zero"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="OrganizationSlugReservation",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("slug", models.SlugField(unique=True)),
+                (
+                    "organization_id",
+                    sentry.db.models.fields.bounded.BoundedBigIntegerField(db_index=True),
+                ),
+                ("user_id", sentry.db.models.fields.bounded.BoundedBigIntegerField(db_index=True)),
+                ("region_name", models.CharField(max_length=48)),
+                (
+                    "reservation_type",
+                    sentry.db.models.fields.bounded.BoundedBigIntegerField(default=0),
+                ),
+                (
+                    "date_added",
+                    models.DateTimeField(default=django.utils.timezone.now, editable=False),
+                ),
+            ],
+            options={
+                "db_table": "sentry_organizationslugreservation",
+                "unique_together": {("organization_id", "reservation_type")},
+            },
+        ),
+    ]

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

@@ -77,6 +77,7 @@ from .organizationmembermapping import *  # NOQA
 from .organizationmemberteam import *  # NOQA
 from .organizationmemberteamreplica import *  # NOQA
 from .organizationonboardingtask import *  # NOQA
+from .organizationslugreservation import *  # NOQA
 from .orgauthtoken import *  # NOQA
 from .outbox import *  # NOQA
 from .platformexternalissue import *  # NOQA

+ 112 - 0
src/sentry/models/organizationslugreservation.py

@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+from enum import IntEnum
+from typing import Any, Collection, List, Mapping
+
+from django.db import models
+from django.utils import timezone
+from sentry_sdk import capture_exception
+
+from sentry.backup.scopes import RelocationScope
+from sentry.db.models.base import control_silo_only_model, sane_repr
+from sentry.db.models.fields import BoundedBigIntegerField
+from sentry.db.models.outboxes import ReplicatedControlModel
+from sentry.models.outbox import ControlOutboxBase, OutboxCategory
+from sentry.services.hybrid_cloud import REGION_NAME_LENGTH
+from sentry.utils.env import in_test_environment
+
+
+class OrganizationSlugReservationType(IntEnum):
+    PRIMARY = 0
+    VANITY_ALIAS = 1
+    TEMPORARY_RENAME_ALIAS = 2
+
+    @classmethod
+    def as_choices(cls):
+        return [(i.value, i.value) for i in cls]
+
+
+@control_silo_only_model
+class OrganizationSlugReservation(ReplicatedControlModel):
+    __relocation_scope__ = RelocationScope.Excluded
+    category = OutboxCategory.ORGANIZATION_SLUG_RESERVATION_UPDATE
+    replication_version = 1
+
+    slug = models.SlugField(unique=True, null=False)
+    organization_id = BoundedBigIntegerField(db_index=True, null=False)
+    user_id = BoundedBigIntegerField(db_index=True, null=False)
+    region_name = models.CharField(max_length=REGION_NAME_LENGTH, null=False)
+    reservation_type = BoundedBigIntegerField(
+        choices=OrganizationSlugReservationType.as_choices(),
+        null=False,
+        default=OrganizationSlugReservationType.PRIMARY.value,
+    )
+    date_added = models.DateTimeField(null=False, default=timezone.now, editable=False)
+
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_organizationslugreservation"
+        unique_together = (("organization_id", "reservation_type"),)
+
+    __repr__ = sane_repr("slug", "organization_id")
+
+    def save(self, *args: Any, **kwds: Any) -> None:
+        assert kwds.get(
+            "unsafe_write", None
+        ), "Cannot write changes to OrganizationSlugReservation unless they go through a provisioning flow"
+
+        kwds.pop("unsafe_write")
+        return super().save(*args, **kwds)
+
+    def update(self, *args: Any, **kwds: Any):
+        assert kwds.get(
+            "unsafe_write", None
+        ), "Cannot write changes to OrganizationSlugReservation unless they go through a provisioning flow"
+
+        kwds.pop("unsafe_write")
+        return super().update(*args, **kwds)
+
+    def outbox_region_names(self) -> Collection[str]:
+        return self.region_name
+
+    def outboxes_for_update(self, shard_identifier: int | None = None) -> List[ControlOutboxBase]:
+        outboxes = super().outboxes_for_update()
+        for outbox in outboxes:
+            outbox.payload = dict(organization_id=self.organization_id, slug=self.slug)
+
+        return outboxes
+
+    def handle_async_replication(self, region_name: str, shard_identifier: int) -> None:
+        from sentry.services.hybrid_cloud.organization_provisioning.serial import (
+            serialize_slug_reservation,
+        )
+        from sentry.services.hybrid_cloud.replica import region_replica_service
+
+        serialized = serialize_slug_reservation(self)
+        region_replica_service.upsert_replicated_org_slug_reservation(
+            slug_reservation=serialized, region_name=self.region_name
+        )
+
+    @classmethod
+    def handle_async_deletion(
+        cls,
+        identifier: int,
+        region_name: str,
+        shard_identifier: int,
+        payload: Mapping[str, Any] | None,
+    ) -> None:
+        if payload is None:
+            capture_exception(Exception("Attempted async deletion on org slug without a payload"))
+
+            if in_test_environment():
+                raise
+
+            return
+        org_id = payload.get("organization_id", None)
+        slug = payload.get("slug", None)
+
+        from sentry.services.hybrid_cloud.replica import region_replica_service
+
+        region_replica_service.delete_replicated_org_slug_reservation(
+            slug=slug, organization_id=org_id
+        )

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

@@ -101,6 +101,7 @@ class OutboxCategory(IntEnum):
     AUTH_PROVIDER_UPDATE = 24
     AUTH_IDENTITY_UPDATE = 25
     ORGANIZATION_MEMBER_TEAM_UPDATE = 26
+    ORGANIZATION_SLUG_RESERVATION_UPDATE = 27
 
     @classmethod
     def as_choices(cls):
@@ -288,6 +289,7 @@ class OutboxScope(IntEnum):
             OutboxCategory.AUTH_PROVIDER_UPDATE,
             OutboxCategory.AUTH_IDENTITY_UPDATE,
             OutboxCategory.ORGANIZATION_MEMBER_TEAM_UPDATE,
+            OutboxCategory.ORGANIZATION_SLUG_RESERVATION_UPDATE,
         },
     )
     USER_SCOPE = scope_categories(

+ 8 - 0
src/sentry/options/defaults.py

@@ -1631,3 +1631,11 @@ register(
     default=0,
     flags=FLAG_AUTOMATOR_MODIFIABLE,
 )
+
+
+register(
+    "outbox_replication.sentry_organizationslugreservation.replication_version",
+    type=Int,
+    default=0,
+    flags=FLAG_AUTOMATOR_MODIFIABLE,
+)

+ 7 - 0
src/sentry/services/hybrid_cloud/organization_provisioning/model.py

@@ -19,3 +19,10 @@ class PostProvisionOptions(pydantic.BaseModel):
 class OrganizationProvisioningOptions(pydantic.BaseModel):
     provision_options: OrganizationOptions
     post_provision_options: PostProvisionOptions
+
+
+class RpcOrganizationSlugReservation(pydantic.BaseModel):
+    organization_id: int
+    user_id: int
+    slug: str
+    region_name: str

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