Repository: getsentry/sentry
Pull request #88484: fix(aci): Add migration to check group id for exclusion constraint
Author: login: snigdhas
Labels: name: Scope: Backend
description: Automatically applied to PRs that change backend components
Created: 2025-04-01T20:26:39Z | Merged: 2025-04-02T22:43:01Z

The existing exclusion constraint looks only for overlapping ranges of (date_started, date_ended). We need to enforce this constraint for each group, so that no distinct group has two open periods that overlap. The table itself is expected to have multiple overlapping ranges since we'll have one row per group.


Diff:
diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt
index b1e9b402588439..3b82f58cdb8ba2 100644
--- a/migrations_lockfile.txt
+++ b/migrations_lockfile.txt
@@ -17,7 +17,7 @@ remote_subscriptions: 0003_drop_remote_subscription
 
 replays: 0004_index_together
 
-sentry: 0856_monitors_remove_type_column_state
+sentry: 0857_update_group_open_periods_constraint
 
 social_auth: 0002_default_auto_field
 
diff --git a/src/sentry/migrations/0857_update_group_open_periods_constraint.py b/src/sentry/migrations/0857_update_group_open_periods_constraint.py
new file mode 100644
index 00000000000000..436207a489a88e
--- /dev/null
+++ b/src/sentry/migrations/0857_update_group_open_periods_constraint.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.1.7 on 2025-04-01 20:22
+
+from django.db import migrations
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.new_migrations.monkey.special import SafeRunSQL
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # 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 post deployment:
+    # - Large data migrations. Typically we want these to be run manually 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
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+
+    dependencies = [
+        ("sentry", "0856_monitors_remove_type_column_state"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="groupopenperiod",
+            name="exclude_open_period_overlap",
+        ),
+        SafeRunSQL(
+            """ALTER TABLE "sentry_groupopenperiod"
+                ADD CONSTRAINT "exclude_open_period_overlap" EXCLUDE USING GIST (
+                    "group_id" gist_int8_ops WITH =,
+                    (TSTZRANGE("date_started", "date_ended", '[)')) WITH &&
+                );""",
+            use_statement_timeout=False,
+            hints={"tables": ["sentry_groupopenperiod"]},
+        ),
+    ]
diff --git a/src/sentry/models/groupopenperiod.py b/src/sentry/models/groupopenperiod.py
index 14da58c13578c0..69d6be3812b7be 100644
--- a/src/sentry/models/groupopenperiod.py
+++ b/src/sentry/models/groupopenperiod.py
@@ -1,6 +1,5 @@
 from django.conf import settings
-from django.contrib.postgres.constraints import ExclusionConstraint
-from django.contrib.postgres.fields import DateTimeRangeField, RangeBoundary, RangeOperators
+from django.contrib.postgres.fields import DateTimeRangeField
 from django.db import models
 from django.utils import timezone
 
@@ -44,16 +43,20 @@ class Meta:
             # get all open periods since a certain date
             models.Index(fields=("group", "date_started")),
         )
-        constraints = (
-            ExclusionConstraint(
-                name="exclude_open_period_overlap",
-                expressions=[
-                    (
-                        TsTzRange("date_started", "date_ended", RangeBoundary()),
-                        RangeOperators.OVERLAPS,
-                    )
-                ],
-            ),
-        )
+
+        # This constraint is applied in the db but can't be represented in the model because Django
+        # doesn't support specifying the opsclass needed to use the bigint field (group_id) in the constraint.
+        # constraints = (
+        #     ExclusionConstraint(
+        #         name="exclude_open_period_overlap",
+        #         expressions=[
+        #             (models.F("group"), RangeOperators.EQUAL),
+        #             (
+        #                 TsTzRange("date_started", "date_ended", RangeBoundary()),
+        #                 RangeOperators.OVERLAPS,
+        #             ),
+        #         ],
+        #     ),
+        # )
 
     __repr__ = sane_repr("project_id", "group_id", "date_started", "date_ended", "user_id")


Reviews:
user: login: wedamija
body: Thanks, this seems right!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-01T21:11:59Z
user: login: wedamija
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T21:14:19Z
user: login: snigdhas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T01:18:32Z
user: login: wedamija
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T17:21:46Z
user: login: wedamija
body: Ahh, damn... I think I know why it's failing on most tests.

When we generate the database without migrations, it looks at the model definition and produces the sql... so it's still creating the GIST in the wrong way.

Basically, there's not a great way to make this index and have it on the table definition.

What we could do is include the `ExclusionConstraint` as a comment where it is now, and explain why we can't actually define it in the code. Then just create it using `SafeRunSQL`, and not have any state representing this exclusion constraint.

You can also set remove the `checked = False` line since we won't be explicitly checking anymore
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T18:52:47Z
user: login: wedamija
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T21:52:37Z
user: login: wedamija
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-02T21:59:28Z

Review comments:
diff_hunk: @@ -0,0 +1,52 @@
+# Generated by Django 5.1.7 on 2025-04-01 20:22
+
+import django.contrib.postgres.constraints
+import django.contrib.postgres.fields.ranges
+from django.db import migrations, models
+
+import sentry.models.groupopenperiod
+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.
+    # 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 post deployment:
+    # - Large data migrations. Typically we want these to be run manually 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
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+
+    dependencies = [
+        ("sentry", "0854_add_project_sdk_model"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="groupopenperiod",
path: src/sentry/migrations/0855_update_group_open_periods_constraint.py
user: login: wedamija
body: This table is empty right? The migration framework is complaining that this is dangerous, but we can just set `checked=False` on the migration class to skip the check since we know this is safe.
created_at: 2025-04-01T21:13:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,52 @@
+# Generated by Django 5.1.7 on 2025-04-01 20:22
+
+import django.contrib.postgres.constraints
+import django.contrib.postgres.fields.ranges
+from django.db import migrations, models
+
+import sentry.models.groupopenperiod
+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.
+    # 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 post deployment:
+    # - Large data migrations. Typically we want these to be run manually 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
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+
+    dependencies = [
+        ("sentry", "0854_add_project_sdk_model"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="groupopenperiod",
path: src/sentry/migrations/0855_update_group_open_periods_constraint.py
user: login: snigdhas
body: ty, added
created_at: 2025-04-02T01:18:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+# Generated by Django 5.1.7 on 2025-04-01 20:22
+
+import django.contrib.postgres.constraints
+import django.contrib.postgres.fields.ranges
+from django.db import migrations, models
+
+import sentry.models.groupopenperiod
+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.
+    # 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 post deployment:
+    # - Large data migrations. Typically we want these to be run manually 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
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+    checked = False
+
+    dependencies = [
+        ("sentry", "0855_give_monitor_type_a_db_default"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="groupopenperiod",
+            name="exclude_open_period_overlap",
+        ),
+        migrations.AddConstraint(
+            model_name="groupopenperiod",
+            constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+                expressions=[
+                    (models.F("group"), "="),
+                    (
+                        sentry.models.groupopenperiod.TsTzRange(
+                            "date_started",
+                            "date_ended",
+                            django.contrib.postgres.fields.ranges.RangeBoundary(),
+                        ),
+                        "&&",
+                    ),
+                ],
+                name="exclude_open_period_overlap",
+            ),
+        ),
path: src/sentry/migrations/0856_update_group_open_periods_constraint.py
user: login: wedamija
body: So as far as I can tell, we need to pass opsclasses here to use the bigint col in this constraint. There's no way to do this in Django for now.

So let's use SeparateDatabaseAndState. Put this `AddConstraint` in the state, and use add this to the database

```
SafeRunSQL("""ALTER TABLE "sentry_groupopenperiod" 
                ADD CONSTRAINT "exclude_open_period_overlap" EXCLUDE USING GIST (
                    "group_id" gist_int8_ops WITH =,
                    (TSTZRANGE("date_started", "date_ended", '[)')) WITH &&
                );""", use_statement_timeout=False)
```
created_at: 2025-04-02T17:21:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+# Generated by Django 5.1.7 on 2025-04-01 20:22
+
+import django.contrib.postgres.constraints
+import django.contrib.postgres.fields.ranges
+from django.db import migrations, models
+
+import sentry.models.groupopenperiod
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.new_migrations.monkey.special import SafeRunSQL
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # 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 post deployment:
+    # - Large data migrations. Typically we want these to be run manually 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
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+
+    dependencies = [
+        ("sentry", "0856_monitors_remove_type_column_state"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="groupopenperiod",
+            name="exclude_open_period_overlap",
+        ),
+        migrations.SeparateDatabaseAndState(
+            database_operations=[
+                SafeRunSQL(
+                    """ALTER TABLE "sentry_groupopenperiod"
+                ADD CONSTRAINT "exclude_open_period_overlap" EXCLUDE USING GIST (
+                    "group_id" gist_int8_ops WITH =,
+                    (TSTZRANGE("date_started", "date_ended", '[)')) WITH &&
+                );""",
+                    use_statement_timeout=False,
+                    hints={"tables": ["sentry_groupopenperiod"]},
+                )
+            ],
+            state_operations=[
+                migrations.AddConstraint(
+                    model_name="groupopenperiod",
+                    constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+                        expressions=[
+                            (models.F("group"), "="),
+                            (
+                                sentry.models.groupopenperiod.TsTzRange(
+                                    "date_started",
+                                    "date_ended",
+                                    django.contrib.postgres.fields.ranges.RangeBoundary(),
+                                ),
+                                "&&",
+                            ),
+                        ],
+                        name="exclude_open_period_overlap",
+                    ),
+                ),
path: src/sentry/migrations/0857_update_group_open_periods_constraint.py
user: login: wedamija
body: You can remove the state operation, django won't be aware of this because it doesn't exist on the model.  You can remove the `SeparateDatabaseAndState` in general and put the `SafeRunSQL` as its own operation
created_at: 2025-04-02T21:52:37Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-01T20:33:17Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/88484?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
All modified and coverable lines are covered by tests :white_check_mark:

:white_check_mark: All tests successful. No failed tests found.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master   #88484       +/-   ##
===========================================
+ Coverage   32.99%   87.76%   +54.77%     
===========================================
  Files        8480    10027     +1547     
  Lines      474576   567682    +93106     
  Branches    22311    22246       -65     
===========================================
+ Hits       156568   498213   +341645     
+ Misses     317588    69050   -248538     
+ Partials      420      419        -1     
```

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-02T04:58:49Z
body: This PR has a migration; here is the generated SQL for  `src/sentry/migrations/0855_update_group_open_periods_constraint.py` ()

```sql
--
-- Remove constraint exclude_open_period_overlap from model groupopenperiod
--
ALTER TABLE "sentry_groupopenperiod" DROP CONSTRAINT "exclude_open_period_overlap";
--
-- Create constraint exclude_open_period_overlap on model groupopenperiod
--
ALTER TABLE "sentry_groupopenperiod" ADD CONSTRAINT "exclude_open_period_overlap" EXCLUDE USING GIST ("group_id" WITH =, (TSTZRANGE("date_started", "date_ended", '[)')) WITH &&);
```
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-02T16:25:53Z
body: This PR has a migration; here is the generated SQL for  `src/sentry/migrations/0856_update_group_open_periods_constraint.py` ()

```sql
--
-- Remove constraint exclude_open_period_overlap from model groupopenperiod
--
ALTER TABLE "sentry_groupopenperiod" DROP CONSTRAINT "exclude_open_period_overlap";
--
-- Custom state/database change combination
--
ALTER TABLE "sentry_groupopenperiod"
                ADD CONSTRAINT "exclude_open_period_overlap" EXCLUDE USING GIST (
                    "group_id" gist_int8_ops WITH =,
                    (TSTZRANGE("date_started", "date_ended", '[)')) WITH &&
                );
```
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-02T21:45:38Z
body: This PR has a migration; here is the generated SQL for  `src/sentry/migrations/0857_update_group_open_periods_constraint.py` ()

```sql
--
-- Remove constraint exclude_open_period_overlap from model groupopenperiod
--
ALTER TABLE "sentry_groupopenperiod" DROP CONSTRAINT "exclude_open_period_overlap";
--
-- Raw SQL operation
--
ALTER TABLE "sentry_groupopenperiod"
                ADD CONSTRAINT "exclude_open_period_overlap" EXCLUDE USING GIST (
                    "group_id" gist_int8_ops WITH =,
                    (TSTZRANGE("date_started", "date_ended", '[)')) WITH &&
                );
```
author_association: CONTRIBUTOR