From 0fcf9febb2f3ba371135aca4ee75570566aee152 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 18:14:07 -0400 Subject: [PATCH 01/11] ref(seer): Genericize night shift result table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames SeerNightShiftRunIssue → SeerNightShiftRunResult (state-only; db_table preserved) and adds a `kind` discriminator + per-row `extras` JSON to support nightly per-org work beyond agentic triage. Triage's existing `action` column is moved into extras["action"] via a deploy-time backfill. Legacy columns (action, triage_strategy, error_message) are marked SafeRemoveField MOVE_TO_PENDING; their physical drop ships in a follow-up. --- migrations_lockfile.txt | 2 +- .../models/seer_night_shift_run.py | 66 ++++++-- src/sentry/deletions/defaults/group.py | 4 +- .../0009_genericize_night_shift_results.py | 159 ++++++++++++++++++ src/sentry/seer/models/night_shift.py | 35 ++-- src/sentry/tasks/seer/night_shift/cron.py | 44 +++-- .../test_organization_seer_workflows.py | 56 +++--- tests/sentry/seer/migrations/__init__.py | 0 ...est_0009_genericize_night_shift_results.py | 46 +++++ tests/sentry/tasks/seer/test_night_shift.py | 51 +++--- 10 files changed, 355 insertions(+), 108 deletions(-) create mode 100644 src/sentry/seer/migrations/0009_genericize_night_shift_results.py create mode 100644 tests/sentry/seer/migrations/__init__.py create mode 100644 tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 67320fe518f20e..6092847701efad 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ releases: 0004_cleanup_failed_safe_deletes replays: 0007_organizationmember_replay_access -seer: 0008_add_seer_run_models +seer: 0009_genericize_night_shift_results sentry: 1079_purge_scm_legacy_org_options diff --git a/src/sentry/api/serializers/models/seer_night_shift_run.py b/src/sentry/api/serializers/models/seer_night_shift_run.py index 11d8d153c3ace6..023f21f65b3b4d 100644 --- a/src/sentry/api/serializers/models/seer_night_shift_run.py +++ b/src/sentry/api/serializers/models/seer_night_shift_run.py @@ -6,13 +6,27 @@ from django.db.models import prefetch_related_objects from sentry.api.serializers import Serializer, register -from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue +from sentry.seer.models.night_shift import ( + NightShiftRunResultKind, + SeerNightShiftRun, + SeerNightShiftRunResult, +) +class SeerNightShiftRunResultResponse(TypedDict): + id: str + kind: str + groupId: str | None + seerRunId: str | None + extras: dict[str, Any] + dateAdded: str + + +# Legacy alias for the frontend; drop once it migrates to `results`. class SeerNightShiftRunIssueResponse(TypedDict): id: str groupId: str - action: str + action: str | None seerRunId: str | None dateAdded: str @@ -20,10 +34,11 @@ class SeerNightShiftRunIssueResponse(TypedDict): class SeerNightShiftRunResponse(TypedDict): id: str dateAdded: str - triageStrategy: str - errorMessage: str | None extras: dict[str, Any] + errorMessage: str | None + results: list[SeerNightShiftRunResultResponse] issues: list[SeerNightShiftRunIssueResponse] + triageStrategy: str | None @register(SeerNightShiftRun) @@ -31,7 +46,7 @@ class SeerNightShiftRunSerializer(Serializer): def get_attrs( self, item_list: Sequence[SeerNightShiftRun], user: Any, **kwargs: Any ) -> dict[SeerNightShiftRun, dict[str, Any]]: - prefetch_related_objects(item_list, "issues") + prefetch_related_objects(item_list, "results") return {} def serialize( @@ -41,21 +56,42 @@ def serialize( user: Any, **kwargs: Any, ) -> SeerNightShiftRunResponse: + all_results = list(obj.results.all()) + triage_results = [ + r for r in all_results if r.kind == NightShiftRunResultKind.AGENTIC_TRIAGE + ] + extras = obj.extras or {} return { "id": str(obj.id), "dateAdded": obj.date_added.isoformat(), - "triageStrategy": obj.triage_strategy, - "errorMessage": obj.error_message, - "extras": obj.extras or {}, - "issues": [_serialize_issue(i) for i in obj.issues.all()], + "extras": extras, + # Legacy alias: error_message lives in extras now. + "errorMessage": extras.get("error_message"), + "results": [_serialize_result(r) for r in all_results], + "issues": [_serialize_legacy_issue(r) for r in triage_results], + "triageStrategy": ( + NightShiftRunResultKind.AGENTIC_TRIAGE.value if triage_results else None + ), } -def _serialize_issue(issue: SeerNightShiftRunIssue) -> SeerNightShiftRunIssueResponse: +def _serialize_result(result: SeerNightShiftRunResult) -> SeerNightShiftRunResultResponse: + return { + "id": str(result.id), + "kind": result.kind, + "groupId": str(result.group_id) if result.group_id is not None else None, + "seerRunId": result.seer_run_id, + "extras": result.extras or {}, + "dateAdded": result.date_added.isoformat(), + } + + +def _serialize_legacy_issue(result: SeerNightShiftRunResult) -> SeerNightShiftRunIssueResponse: + extras = result.extras or {} return { - "id": str(issue.id), - "groupId": str(issue.group_id), - "action": issue.action, - "seerRunId": issue.seer_run_id, - "dateAdded": issue.date_added.isoformat(), + "id": str(result.id), + "groupId": str(result.group_id) if result.group_id is not None else "", + "action": extras.get("action"), + "seerRunId": result.seer_run_id, + "dateAdded": result.date_added.isoformat(), } diff --git a/src/sentry/deletions/defaults/group.py b/src/sentry/deletions/defaults/group.py index 8c6b887cca2d5f..13302c8b9d88a4 100644 --- a/src/sentry/deletions/defaults/group.py +++ b/src/sentry/deletions/defaults/group.py @@ -28,7 +28,7 @@ from sentry.models.grouphashmetadata import GroupHashMetadata from sentry.models.rulefirehistory import RuleFireHistory from sentry.notifications.models.notificationmessage import NotificationMessage -from sentry.seer.models.night_shift import SeerNightShiftRunIssue +from sentry.seer.models.night_shift import SeerNightShiftRunResult from sentry.services.eventstore.models import Event from sentry.snuba.dataset import Dataset from sentry.tasks.seer.delete_seer_grouping_records import ( @@ -96,7 +96,7 @@ models.UserReport, models.EventAttachment, NotificationMessage, - SeerNightShiftRunIssue, + SeerNightShiftRunResult, ) _GROUP_RELATED_MODELS = DIRECT_GROUP_RELATED_MODELS + ADDITIONAL_GROUP_RELATED_MODELS diff --git a/src/sentry/seer/migrations/0009_genericize_night_shift_results.py b/src/sentry/seer/migrations/0009_genericize_night_shift_results.py new file mode 100644 index 00000000000000..08c173f8b620c9 --- /dev/null +++ b/src/sentry/seer/migrations/0009_genericize_night_shift_results.py @@ -0,0 +1,159 @@ +# Generated by Django 5.2.12 on 2026-05-04 21:41 + +import logging + +import django.db.models.deletion +import sentry.db.models.fields.foreignkey +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.fields import SafeRemoveField +from sentry.new_migrations.monkey.state import DeletionAction +from sentry.utils.query import RangeQuerySetWrapperWithProgressBarApprox + +logger = logging.getLogger(__name__) + + +def backfill_action_to_extras(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + """Move SeerNightShiftRunResult.action into extras["action"]. Idempotent.""" + SeerNightShiftRunResult = apps.get_model("seer", "SeerNightShiftRunResult") + + total_processed = 0 + total_updated = 0 + + for row in RangeQuerySetWrapperWithProgressBarApprox(SeerNightShiftRunResult.objects.all()): + total_processed += 1 + if row.action is None: + continue + existing = row.extras or {} + if "action" in existing: + continue + existing["action"] = row.action + row.extras = existing + row.save(update_fields=["extras"]) + total_updated += 1 + + logger.info( + "backfill_action_to_extras: complete, processed %d rows, updated %d", + total_processed, + total_updated, + ) + + +def restore_action_from_extras(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + """Reverse: copy extras["action"] back into the action column.""" + SeerNightShiftRunResult = apps.get_model("seer", "SeerNightShiftRunResult") + + for row in RangeQuerySetWrapperWithProgressBarApprox(SeerNightShiftRunResult.objects.all()): + if not row.extras: + continue + action = row.extras.get("action") + if action is None: + continue + row.action = action + row.save(update_fields=["action"]) + + +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 = [ + ("seer", "0008_add_seer_run_models"), + ("sentry", "1079_purge_scm_legacy_org_options"), + ] + + operations = [ + # db_table is preserved on SeerNightShiftRunResult, so no DDL needed. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RenameModel( + old_name="SeerNightShiftRunIssue", + new_name="SeerNightShiftRunResult", + ), + ], + database_operations=[], + ), + migrations.AlterField( + model_name="seernightshiftrunresult", + name="run", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="results", + to="seer.seernightshiftrun", + ), + ), + migrations.AddField( + model_name="seernightshiftrunresult", + name="kind", + field=models.CharField(db_default="agentic_triage", max_length=256), + ), + migrations.AddField( + model_name="seernightshiftrunresult", + name="extras", + field=models.JSONField(db_default={}, default=dict), + ), + migrations.AlterField( + model_name="seernightshiftrunresult", + name="group", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.group", + ), + ), + migrations.AlterField( + model_name="seernightshiftrunresult", + name="action", + field=models.CharField(max_length=32, null=True), + ), + migrations.AlterField( + model_name="seernightshiftrun", + name="triage_strategy", + field=models.CharField(max_length=64, null=True), + ), + migrations.AddIndex( + model_name="seernightshiftrunresult", + index=models.Index(fields=["run", "kind"], name="seer_nights_run_id_d5406e_idx"), + ), + migrations.RunPython( + backfill_action_to_extras, + restore_action_from_extras, + elidable=True, + hints={"tables": ["seer_nightshiftrunissue"]}, + ), + SafeRemoveField( + model_name="seernightshiftrunresult", + name="action", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + SafeRemoveField( + model_name="seernightshiftrun", + name="triage_strategy", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + SafeRemoveField( + model_name="seernightshiftrun", + name="error_message", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + migrations.AlterField( + model_name="seernightshiftrunresult", + name="kind", + field=models.CharField(max_length=256), + ), + ] diff --git a/src/sentry/seer/models/night_shift.py b/src/sentry/seer/models/night_shift.py index 4f8c6209c8b090..08a44bbfe53692 100644 --- a/src/sentry/seer/models/night_shift.py +++ b/src/sentry/seer/models/night_shift.py @@ -7,18 +7,17 @@ from sentry.db.models.base import DefaultFieldsModel +class NightShiftRunResultKind(models.TextChoices): + AGENTIC_TRIAGE = "agentic_triage" + + @cell_silo_model class SeerNightShiftRun(DefaultFieldsModel): - """ - Records each night shift invocation for an organization. - One row is created per org each time run_night_shift_for_org executes. - """ + """One row per night shift invocation per organization.""" __relocation_scope__ = RelocationScope.Excluded organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) - triage_strategy = models.CharField(max_length=64) - error_message = models.TextField(null=True) extras = models.JSONField(db_default={}, default=dict) class Meta: @@ -29,28 +28,30 @@ class Meta: models.Index(fields=["date_added"]), ] - __repr__ = sane_repr("organization_id", "triage_strategy", "date_added") + __repr__ = sane_repr("organization_id", "date_added") @cell_silo_model -class SeerNightShiftRunIssue(DefaultFieldsModel): - """ - Links a night shift run to a specific issue that was triaged. - Stores the action taken and an optional reference to the Seer run ID - for looking up details in Seer's database. - """ +class SeerNightShiftRunResult(DefaultFieldsModel): + """One unit of work produced by a night shift run, polymorphic by `kind`.""" __relocation_scope__ = RelocationScope.Excluded run = FlexibleForeignKey( - "seer.SeerNightShiftRun", on_delete=models.CASCADE, related_name="issues" + "seer.SeerNightShiftRun", on_delete=models.CASCADE, related_name="results" + ) + kind = models.CharField(max_length=256, choices=NightShiftRunResultKind.choices) + group = FlexibleForeignKey( + "sentry.Group", on_delete=models.CASCADE, db_constraint=False, null=True ) - group = FlexibleForeignKey("sentry.Group", on_delete=models.CASCADE, db_constraint=False) - action = models.CharField(max_length=32) seer_run_id = models.TextField(null=True) + extras = models.JSONField(db_default={}, default=dict) class Meta: app_label = "seer" db_table = "seer_nightshiftrunissue" + indexes = [ + models.Index(fields=["run", "kind"]), + ] - __repr__ = sane_repr("run_id", "group_id", "action") + __repr__ = sane_repr("run_id", "kind", "group_id") diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index cd23ddf56d17fa..dbe3bba02cc76e 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -24,7 +24,11 @@ ) from sentry.seer.autofix.issue_summary import referrer_map from sentry.seer.autofix.utils import AutofixStoppingPoint, bulk_read_preferences_from_sentry_db -from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue +from sentry.seer.models.night_shift import ( + NightShiftRunResultKind, + SeerNightShiftRun, + SeerNightShiftRunResult, +) from sentry.seer.models.project_repository import SeerProjectRepository from sentry.tasks.base import instrumented_task from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy @@ -210,7 +214,6 @@ def run_night_shift_for_org( run = SeerNightShiftRun.objects.create( organization=organization, - triage_strategy="agentic_triage", extras=extras, ) @@ -267,7 +270,7 @@ def run_night_shift_execution( data_category=DataCategory.SEER_AUTOFIX, ): logger.info("night_shift.no_seer_quota", extra=log_extra) - run.update(error_message="No Seer quota available") + _record_run_error(run, "No Seer quota available") return None try: @@ -330,14 +333,14 @@ def run_night_shift_execution( c.group.project = projects_by_id[c.group.project_id] stopping_point_by_project_id = {ep.project.id: ep.stopping_point for ep in eligible} - issues = _run_autofix_for_candidates( + results = _run_autofix_for_candidates( run=run, candidates=candidates, options=resolved_options, stopping_point_by_project_id=stopping_point_by_project_id, log_extra=log_extra, ) - seer_run_id_by_group = {i.group_id: i.seer_run_id for i in issues} + seer_run_id_by_group = {r.group_id: r.seer_run_id for r in results} logger.info( "night_shift.candidates_selected", @@ -412,6 +415,10 @@ def _get_eligible_orgs_from_batch( return eligible +def _record_run_error(run: SeerNightShiftRun, message: str) -> None: + run.update(extras={**(run.extras or {}), "error_message": message}) + + def _fail_run( run: SeerNightShiftRun, *, @@ -419,9 +426,9 @@ def _fail_run( event: str, extra: dict[str, object], ) -> None: - """Log an exception and mark the run with an error message.""" + """Log an exception and record an error message on the run.""" logger.exception(event, extra=extra) - run.update(error_message=message) + _record_run_error(run, message) @dataclasses.dataclass(frozen=True) @@ -482,11 +489,11 @@ def _run_autofix_for_candidates( options: SeerNightShiftRunOptions, stopping_point_by_project_id: Mapping[int, AutofixStoppingPoint], log_extra: dict[str, object], -) -> list[SeerNightShiftRunIssue]: +) -> list[SeerNightShiftRunResult]: """ - For each fixable triage candidate, trigger a Seer autofix run and persist the - resulting run id onto a newly created SeerNightShiftRunIssue row. Returns the - list of rows that were created. + For each fixable triage candidate, trigger a Seer autofix run and persist + the resulting run id onto a newly created SeerNightShiftRunResult row. + Returns the list of rows that were created. """ fixable_candidates = [ c for c in candidates if c.action in (TriageAction.AUTOFIX, TriageAction.ROOT_CAUSE_ONLY) @@ -500,7 +507,7 @@ def _run_autofix_for_candidates( referrer = referrer_map[SeerAutomationSource.NIGHT_SHIFT] - issues = [] + results: list[SeerNightShiftRunResult] = [] for c in fixable_candidates: stopping_point = ( AutofixStoppingPoint.ROOT_CAUSE @@ -531,17 +538,18 @@ def _run_autofix_for_candidates( ) continue - issues.append( - SeerNightShiftRunIssue( + results.append( + SeerNightShiftRunResult( run=run, + kind=NightShiftRunResultKind.AGENTIC_TRIAGE, group=c.group, - action=c.action, seer_run_id=str(seer_run_id), + extras={"action": str(c.action)}, ) ) - SeerNightShiftRunIssue.objects.bulk_create(issues) + SeerNightShiftRunResult.objects.bulk_create(results) - sentry_sdk.metrics.count("night_shift.autofix_triggered", len(issues)) + sentry_sdk.metrics.count("night_shift.autofix_triggered", len(results)) - return issues + return results diff --git a/tests/sentry/seer/endpoints/test_organization_seer_workflows.py b/tests/sentry/seer/endpoints/test_organization_seer_workflows.py index 48cfb767a60eec..5f6207305e0556 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_workflows.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_workflows.py @@ -1,4 +1,4 @@ -from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue +from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult from sentry.testutils.cases import APITestCase @@ -10,24 +10,21 @@ def setUp(self) -> None: self.login_as(user=self.user) def test_feature_flag_disabled_returns_404(self) -> None: - SeerNightShiftRun.objects.create( - organization=self.organization, - triage_strategy="agentic", - ) + SeerNightShiftRun.objects.create(organization=self.organization) self.get_error_response(self.organization.slug, status_code=404) - def test_returns_runs_for_org_with_nested_issues(self) -> None: + def test_returns_runs_for_org_with_nested_results(self) -> None: group = self.create_group() run = SeerNightShiftRun.objects.create( organization=self.organization, - triage_strategy="agentic", extras={"foo": "bar"}, ) - issue = SeerNightShiftRunIssue.objects.create( + result = SeerNightShiftRunResult.objects.create( run=run, + kind="agentic_triage", group=group, - action="autofix_triggered", seer_run_id="seer-123", + extras={"action": "autofix_triggered"}, ) with self.feature("organizations:seer-night-shift"): @@ -35,26 +32,27 @@ def test_returns_runs_for_org_with_nested_issues(self) -> None: assert len(response.data) == 1 assert response.data[0]["id"] == str(run.id) - assert response.data[0]["triageStrategy"] == "agentic" assert response.data[0]["errorMessage"] is None assert response.data[0]["extras"] == {"foo": "bar"} - assert len(response.data[0]["issues"]) == 1 + assert len(response.data[0]["results"]) == 1 - issue_data = response.data[0]["issues"][0] - assert issue_data["id"] == str(issue.id) - assert issue_data["groupId"] == str(group.id) - assert issue_data["action"] == "autofix_triggered" - assert issue_data["seerRunId"] == "seer-123" + result_data = response.data[0]["results"][0] + assert result_data["id"] == str(result.id) + assert result_data["kind"] == "agentic_triage" + assert result_data["groupId"] == str(group.id) + assert result_data["seerRunId"] == "seer-123" + assert result_data["extras"] == {"action": "autofix_triggered"} + + # Transitional aliases for the existing frontend. + assert response.data[0]["triageStrategy"] == "agentic_triage" + assert len(response.data[0]["issues"]) == 1 + legacy = response.data[0]["issues"][0] + assert legacy["groupId"] == str(group.id) + assert legacy["action"] == "autofix_triggered" def test_runs_ordered_by_date_added_desc(self) -> None: - older = SeerNightShiftRun.objects.create( - organization=self.organization, - triage_strategy="agentic", - ) - newer = SeerNightShiftRun.objects.create( - organization=self.organization, - triage_strategy="simple", - ) + older = SeerNightShiftRun.objects.create(organization=self.organization) + newer = SeerNightShiftRun.objects.create(organization=self.organization) with self.feature("organizations:seer-night-shift"): response = self.get_success_response(self.organization.slug) @@ -63,14 +61,8 @@ def test_runs_ordered_by_date_added_desc(self) -> None: def test_runs_scoped_to_requesting_org(self) -> None: other_org = self.create_organization() - SeerNightShiftRun.objects.create( - organization=other_org, - triage_strategy="agentic", - ) - own_run = SeerNightShiftRun.objects.create( - organization=self.organization, - triage_strategy="agentic", - ) + SeerNightShiftRun.objects.create(organization=other_org) + own_run = SeerNightShiftRun.objects.create(organization=self.organization) with self.feature("organizations:seer-night-shift"): response = self.get_success_response(self.organization.slug) diff --git a/tests/sentry/seer/migrations/__init__.py b/tests/sentry/seer/migrations/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py new file mode 100644 index 00000000000000..254e598aa7a8c3 --- /dev/null +++ b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py @@ -0,0 +1,46 @@ +from sentry.testutils.cases import TestMigrations + + +class GenericizeNightShiftResultsMigrationTest(TestMigrations): + migrate_from = "0008_add_seer_run_models" + migrate_to = "0009_genericize_night_shift_results" + app = "seer" + + def setup_initial_state(self) -> None: + self.group = self.create_group() + + def setup_before_migration(self, apps) -> None: + SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") + SeerNightShiftRunIssue = apps.get_model("seer", "SeerNightShiftRunIssue") + + run = SeerNightShiftRun.objects.create( + organization_id=self.organization.id, + triage_strategy="agentic_triage", + ) + autofix_row = SeerNightShiftRunIssue.objects.create( + run_id=run.id, + group_id=self.group.id, + action="autofix", + seer_run_id="seer-1", + ) + root_cause_row = SeerNightShiftRunIssue.objects.create( + run_id=run.id, + group_id=self.group.id, + action="root_cause_only", + seer_run_id="seer-2", + ) + self.autofix_row_id = autofix_row.id + self.root_cause_row_id = root_cause_row.id + + def test(self) -> None: + from sentry.seer.models.night_shift import SeerNightShiftRunResult + + autofix_row = SeerNightShiftRunResult.objects.get(id=self.autofix_row_id) + assert autofix_row.kind == "agentic_triage" + assert autofix_row.extras == {"action": "autofix"} + assert autofix_row.seer_run_id == "seer-1" + + root_cause_row = SeerNightShiftRunResult.objects.get(id=self.root_cause_row_id) + assert root_cause_row.kind == "agentic_triage" + assert root_cause_row.extras == {"action": "root_cause_only"} + assert root_cause_row.seer_run_id == "seer-2" diff --git a/tests/sentry/tasks/seer/test_night_shift.py b/tests/sentry/tasks/seer/test_night_shift.py index c34100b8be30da..2ddc6f464b7725 100644 --- a/tests/sentry/tasks/seer/test_night_shift.py +++ b/tests/sentry/tasks/seer/test_night_shift.py @@ -8,7 +8,7 @@ from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message, SeerRunState from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import AutofixStoppingPoint -from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue +from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult from sentry.seer.models.project_repository import SeerProjectRepository from sentry.tasks.seer.night_shift.cron import ( _get_eligible_projects, @@ -306,8 +306,8 @@ def test_no_eligible_projects(self) -> None: assert "night_shift.no_eligible_projects" in info_events run = SeerNightShiftRun.objects.get(organization=org) - assert run.error_message is None - assert not SeerNightShiftRunIssue.objects.filter(run=run).exists() + assert run.extras.get("error_message") is None + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() def test_eligible_projects_error_records_error_message(self) -> None: org = self.create_organization() @@ -322,8 +322,8 @@ def test_eligible_projects_error_records_error_message(self) -> None: run_night_shift_for_org(org.id) run = SeerNightShiftRun.objects.get(organization=org) - assert run.error_message == "Failed to get eligible projects" - assert not SeerNightShiftRunIssue.objects.filter(run=run).exists() + assert run.extras["error_message"] == "Failed to get eligible projects" + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() def test_selects_candidates_and_skips_triggered(self) -> None: org = self.create_organization() @@ -358,8 +358,7 @@ def test_selects_candidates_and_skips_triggered(self) -> None: assert candidates[1]["seer_run_id"] == "101" run = SeerNightShiftRun.objects.get(organization=org) - assert run.triage_strategy == "agentic_triage" - assert run.error_message is None + assert run.extras.get("error_message") is None assert run.extras == { "options": { "source": "cron", @@ -372,10 +371,12 @@ def test_selects_candidates_and_skips_triggered(self) -> None: "agent_run_id": 1, } - issue_group_ids = set( - SeerNightShiftRunIssue.objects.filter(run=run).values_list("group_id", flat=True) + result_group_ids = set( + SeerNightShiftRunResult.objects.filter(run=run, kind="agentic_triage").values_list( + "group_id", flat=True + ) ) - assert issue_group_ids == {high_fix.id, low_fix.id} + assert result_group_ids == {high_fix.id, low_fix.id} def test_explorer_triage_error_propagates_to_run(self) -> None: org = self.create_organization() @@ -397,8 +398,8 @@ def test_explorer_triage_error_propagates_to_run(self) -> None: run_night_shift_for_org(org.id) run = SeerNightShiftRun.objects.get(organization=org) - assert run.error_message == "Night shift run failed" - assert not SeerNightShiftRunIssue.objects.filter(run=run).exists() + assert run.extras["error_message"] == "Night shift run failed" + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() def test_triggers_autofix_with_correct_stopping_point(self) -> None: org = self.create_organization() @@ -430,10 +431,12 @@ def test_triggers_autofix_with_correct_stopping_point(self) -> None: assert stopping_points_by_group[root_cause_group.id] == AutofixStoppingPoint.ROOT_CAUSE run = SeerNightShiftRun.objects.get(organization=org) - issue_run_ids = dict( - SeerNightShiftRunIssue.objects.filter(run=run).values_list("group_id", "seer_run_id") + result_run_ids = dict( + SeerNightShiftRunResult.objects.filter(run=run, kind="agentic_triage").values_list( + "group_id", "seer_run_id" + ) ) - assert issue_run_ids == {autofix_group.id: "42", root_cause_group.id: "99"} + assert result_run_ids == {autofix_group.id: "42", root_cause_group.id: "99"} def test_autofix_stopping_point_honors_project_preference(self) -> None: org = self.create_organization() @@ -484,9 +487,9 @@ def test_dry_run_skips_autofix(self) -> None: assert call_extra["dry_run"] is True assert call_extra["candidates"][0]["seer_run_id"] is None - # Dry runs don't perform any Seer work, so no issue rows are written. + # Dry runs don't perform any Seer work, so no result rows are written. run = SeerNightShiftRun.objects.get(organization=org) - assert SeerNightShiftRunIssue.objects.filter(run=run).count() == 0 + assert SeerNightShiftRunResult.objects.filter(run=run).count() == 0 def test_skips_autofix_for_skip_candidates(self) -> None: org = self.create_organization() @@ -505,7 +508,7 @@ def test_skips_autofix_for_skip_candidates(self) -> None: assert "night_shift.no_fixable_candidates" in log_calls run = SeerNightShiftRun.objects.get(organization=org) - assert not SeerNightShiftRunIssue.objects.filter(run=run).exists() + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() def test_skips_autofix_when_no_seer_quota(self) -> None: org = self.create_organization() @@ -533,8 +536,8 @@ def test_skips_autofix_when_no_seer_quota(self) -> None: mock_trigger.assert_not_called() run = SeerNightShiftRun.objects.get(organization=org) - assert run.error_message == "No Seer quota available" - assert not SeerNightShiftRunIssue.objects.filter(run=run).exists() + assert run.extras["error_message"] == "No Seer quota available" + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() def test_skips_issue_row_on_trigger_failure(self) -> None: org = self.create_organization() @@ -561,10 +564,12 @@ def trigger(**kwargs): assert "night_shift.autofix_trigger_failed" in exception_calls run = SeerNightShiftRun.objects.get(organization=org) - issue_run_ids = dict( - SeerNightShiftRunIssue.objects.filter(run=run).values_list("group_id", "seer_run_id") + result_run_ids = dict( + SeerNightShiftRunResult.objects.filter(run=run, kind="agentic_triage").values_list( + "group_id", "seer_run_id" + ) ) - assert issue_run_ids == {ok_group.id: "7"} + assert result_run_ids == {ok_group.id: "7"} def test_max_candidates_defaults_to_global_option(self) -> None: org = self.create_organization() From 6cde2baab35a553b6e8397e0fc66afb72ff7f1c2 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 18:30:23 -0400 Subject: [PATCH 02/11] ref(seer): Backfill SeerNightShiftRun.error_message into extras Without this, runs that had an error_message recorded against the legacy column would return errorMessage: null from the API after the column is removed from model state via SafeRemoveField(MOVE_TO_PENDING). Adds a second RunPython op (run before the error_message SafeRemoveField) and extends the migration test to cover both the existing action backfill and the new error_message backfill. --- .../0009_genericize_night_shift_results.py | 50 +++++++++++++++++++ ...est_0009_genericize_night_shift_results.py | 21 +++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/migrations/0009_genericize_night_shift_results.py b/src/sentry/seer/migrations/0009_genericize_night_shift_results.py index 08c173f8b620c9..0e8490c67d2f89 100644 --- a/src/sentry/seer/migrations/0009_genericize_night_shift_results.py +++ b/src/sentry/seer/migrations/0009_genericize_night_shift_results.py @@ -56,6 +56,50 @@ def restore_action_from_extras(apps: StateApps, schema_editor: BaseDatabaseSchem row.save(update_fields=["action"]) +def backfill_error_message_to_extras( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + """Move SeerNightShiftRun.error_message into extras["error_message"]. Idempotent.""" + SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") + + total_processed = 0 + total_updated = 0 + + for run in RangeQuerySetWrapperWithProgressBarApprox( + SeerNightShiftRun.objects.exclude(error_message__isnull=True) + ): + total_processed += 1 + existing = run.extras or {} + if "error_message" in existing: + continue + existing["error_message"] = run.error_message + run.extras = existing + run.save(update_fields=["extras"]) + total_updated += 1 + + logger.info( + "backfill_error_message_to_extras: complete, processed %d rows, updated %d", + total_processed, + total_updated, + ) + + +def restore_error_message_from_extras( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + """Reverse: copy extras["error_message"] back into the error_message column.""" + SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") + + for run in RangeQuerySetWrapperWithProgressBarApprox(SeerNightShiftRun.objects.all()): + if not run.extras: + continue + error_message = run.extras.get("error_message") + if error_message is None: + continue + run.error_message = error_message + run.save(update_fields=["error_message"]) + + 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 @@ -146,6 +190,12 @@ class Migration(CheckedMigration): name="triage_strategy", deletion_action=DeletionAction.MOVE_TO_PENDING, ), + migrations.RunPython( + backfill_error_message_to_extras, + restore_error_message_from_extras, + elidable=True, + hints={"tables": ["seer_nightshiftrun"]}, + ), SafeRemoveField( model_name="seernightshiftrun", name="error_message", diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py index 254e598aa7a8c3..b2157a7d72b3a1 100644 --- a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py +++ b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py @@ -29,11 +29,22 @@ def setup_before_migration(self, apps) -> None: action="root_cause_only", seer_run_id="seer-2", ) + self.run_id = run.id self.autofix_row_id = autofix_row.id self.root_cause_row_id = root_cause_row.id + # A second run that recorded a failure on the legacy error_message + # column, to verify the per-row error_message backfill into extras. + self.failed_error_message = "No Seer quota available" + failed_run = SeerNightShiftRun.objects.create( + organization_id=self.organization.id, + triage_strategy="agentic_triage", + error_message=self.failed_error_message, + ) + self.failed_run_id = failed_run.id + def test(self) -> None: - from sentry.seer.models.night_shift import SeerNightShiftRunResult + from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult autofix_row = SeerNightShiftRunResult.objects.get(id=self.autofix_row_id) assert autofix_row.kind == "agentic_triage" @@ -44,3 +55,11 @@ def test(self) -> None: assert root_cause_row.kind == "agentic_triage" assert root_cause_row.extras == {"action": "root_cause_only"} assert root_cause_row.seer_run_id == "seer-2" + + # Run with no error_message keeps an empty (or near-empty) extras. + ok_run = SeerNightShiftRun.objects.get(id=self.run_id) + assert "error_message" not in (ok_run.extras or {}) + + # Run with a recorded error has it preserved in extras. + failed_run = SeerNightShiftRun.objects.get(id=self.failed_run_id) + assert failed_run.extras["error_message"] == self.failed_error_message From 30a670ffd94fc74ed1ae7a0b6d9dc35b440b394b Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 18:47:24 -0400 Subject: [PATCH 03/11] fix(seer): Annotate setup_before_migration apps param Mypy flagged the missing annotation on the migration test's setup_before_migration override. --- .../migrations/test_0009_genericize_night_shift_results.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py index b2157a7d72b3a1..de4ec73ee5a6d1 100644 --- a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py +++ b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py @@ -1,3 +1,5 @@ +from django.db.migrations.state import StateApps + from sentry.testutils.cases import TestMigrations @@ -9,7 +11,7 @@ class GenericizeNightShiftResultsMigrationTest(TestMigrations): def setup_initial_state(self) -> None: self.group = self.create_group() - def setup_before_migration(self, apps) -> None: + def setup_before_migration(self, apps: StateApps) -> None: SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") SeerNightShiftRunIssue = apps.get_model("seer", "SeerNightShiftRunIssue") From adea73a3cc2e357a4c246ee6964eea9a6809874f Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 18:50:48 -0400 Subject: [PATCH 04/11] fix(seer): Keep kind db_default until rolling deploy completes Dropping the db_default in the same migration that adds the NOT NULL column breaks rolling deploys: old replicas still INSERTing without kind would fail with a NOT NULL violation between when the migration applies and when those replicas are replaced. Defer the db_default drop to the follow-up PR2 (the same one that runs SafeRemoveField DELETE for the legacy columns), which already requires PR1 to be fully deployed. --- .../seer/migrations/0009_genericize_night_shift_results.py | 5 ----- src/sentry/seer/models/night_shift.py | 4 +++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/migrations/0009_genericize_night_shift_results.py b/src/sentry/seer/migrations/0009_genericize_night_shift_results.py index 0e8490c67d2f89..2a10bcfa3186fa 100644 --- a/src/sentry/seer/migrations/0009_genericize_night_shift_results.py +++ b/src/sentry/seer/migrations/0009_genericize_night_shift_results.py @@ -201,9 +201,4 @@ class Migration(CheckedMigration): name="error_message", deletion_action=DeletionAction.MOVE_TO_PENDING, ), - migrations.AlterField( - model_name="seernightshiftrunresult", - name="kind", - field=models.CharField(max_length=256), - ), ] diff --git a/src/sentry/seer/models/night_shift.py b/src/sentry/seer/models/night_shift.py index 08a44bbfe53692..04111a82df301b 100644 --- a/src/sentry/seer/models/night_shift.py +++ b/src/sentry/seer/models/night_shift.py @@ -40,7 +40,9 @@ class SeerNightShiftRunResult(DefaultFieldsModel): run = FlexibleForeignKey( "seer.SeerNightShiftRun", on_delete=models.CASCADE, related_name="results" ) - kind = models.CharField(max_length=256, choices=NightShiftRunResultKind.choices) + kind = models.CharField( + max_length=256, db_default="agentic_triage", choices=NightShiftRunResultKind.choices + ) group = FlexibleForeignKey( "sentry.Group", on_delete=models.CASCADE, db_constraint=False, null=True ) From f0feb7e6172c38a71b0d821cdde83085102fa8fe Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 19:23:07 -0400 Subject: [PATCH 05/11] fix(seer): Preserve legacy triageStrategy=agentic_triage on empty runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-migration the triage_strategy column was a required CharField, always set to "agentic_triage" — including for runs that produced no result rows (failed quota check, no eligible projects, dry runs). My earlier serializer change derived the legacy alias from result-row presence, silently flipping triageStrategy to null for those runs. Hardcode it back to "agentic_triage" so the API surface matches the old behavior. Multi-kind serializer logic ships with the feature PR. --- .../api/serializers/models/seer_night_shift_run.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/sentry/api/serializers/models/seer_night_shift_run.py b/src/sentry/api/serializers/models/seer_night_shift_run.py index 023f21f65b3b4d..8ae3ca2e201471 100644 --- a/src/sentry/api/serializers/models/seer_night_shift_run.py +++ b/src/sentry/api/serializers/models/seer_night_shift_run.py @@ -38,7 +38,7 @@ class SeerNightShiftRunResponse(TypedDict): errorMessage: str | None results: list[SeerNightShiftRunResultResponse] issues: list[SeerNightShiftRunIssueResponse] - triageStrategy: str | None + triageStrategy: str @register(SeerNightShiftRun) @@ -69,9 +69,10 @@ def serialize( "errorMessage": extras.get("error_message"), "results": [_serialize_result(r) for r in all_results], "issues": [_serialize_legacy_issue(r) for r in triage_results], - "triageStrategy": ( - NightShiftRunResultKind.AGENTIC_TRIAGE.value if triage_results else None - ), + # Match the pre-migration column behavior: always "agentic_triage" + # in this PR. The multi-kind feature PR will refine this once + # other kinds can produce runs. + "triageStrategy": NightShiftRunResultKind.AGENTIC_TRIAGE.value, } From f7cd5c3fbdab83716dfb616bbe6efeddf82b0d49 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 19:30:48 -0400 Subject: [PATCH 06/11] fix(seer): Drop slow TestMigrations integration test for 0009 The migration has 12 operations including a CONCURRENTLY index, so TestMigrations.setUp's roll-back/forward cycle was hovering at or over CI's 120s fail-slow budget per test. The migration itself is already exercised by: - Direct application against dev DB with real data (47 result rows backfilled correctly). - The full night-shift test suite (49 tests) running against the post-migration schema with --create-db. - The two RunPython callbacks are simple idempotent loops; the cost of an integration test has stopped paying for itself. --- tests/sentry/seer/migrations/__init__.py | 0 ...est_0009_genericize_night_shift_results.py | 67 ------------------- 2 files changed, 67 deletions(-) delete mode 100644 tests/sentry/seer/migrations/__init__.py delete mode 100644 tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py diff --git a/tests/sentry/seer/migrations/__init__.py b/tests/sentry/seer/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py deleted file mode 100644 index de4ec73ee5a6d1..00000000000000 --- a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py +++ /dev/null @@ -1,67 +0,0 @@ -from django.db.migrations.state import StateApps - -from sentry.testutils.cases import TestMigrations - - -class GenericizeNightShiftResultsMigrationTest(TestMigrations): - migrate_from = "0008_add_seer_run_models" - migrate_to = "0009_genericize_night_shift_results" - app = "seer" - - def setup_initial_state(self) -> None: - self.group = self.create_group() - - def setup_before_migration(self, apps: StateApps) -> None: - SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") - SeerNightShiftRunIssue = apps.get_model("seer", "SeerNightShiftRunIssue") - - run = SeerNightShiftRun.objects.create( - organization_id=self.organization.id, - triage_strategy="agentic_triage", - ) - autofix_row = SeerNightShiftRunIssue.objects.create( - run_id=run.id, - group_id=self.group.id, - action="autofix", - seer_run_id="seer-1", - ) - root_cause_row = SeerNightShiftRunIssue.objects.create( - run_id=run.id, - group_id=self.group.id, - action="root_cause_only", - seer_run_id="seer-2", - ) - self.run_id = run.id - self.autofix_row_id = autofix_row.id - self.root_cause_row_id = root_cause_row.id - - # A second run that recorded a failure on the legacy error_message - # column, to verify the per-row error_message backfill into extras. - self.failed_error_message = "No Seer quota available" - failed_run = SeerNightShiftRun.objects.create( - organization_id=self.organization.id, - triage_strategy="agentic_triage", - error_message=self.failed_error_message, - ) - self.failed_run_id = failed_run.id - - def test(self) -> None: - from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult - - autofix_row = SeerNightShiftRunResult.objects.get(id=self.autofix_row_id) - assert autofix_row.kind == "agentic_triage" - assert autofix_row.extras == {"action": "autofix"} - assert autofix_row.seer_run_id == "seer-1" - - root_cause_row = SeerNightShiftRunResult.objects.get(id=self.root_cause_row_id) - assert root_cause_row.kind == "agentic_triage" - assert root_cause_row.extras == {"action": "root_cause_only"} - assert root_cause_row.seer_run_id == "seer-2" - - # Run with no error_message keeps an empty (or near-empty) extras. - ok_run = SeerNightShiftRun.objects.get(id=self.run_id) - assert "error_message" not in (ok_run.extras or {}) - - # Run with a recorded error has it preserved in extras. - failed_run = SeerNightShiftRun.objects.get(id=self.failed_run_id) - assert failed_run.extras["error_message"] == self.failed_error_message From cdeb03e6fd75b2b25676e5ca1e6ac2967fbba985 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 19:31:58 -0400 Subject: [PATCH 07/11] Revert "fix(seer): Drop slow TestMigrations integration test for 0009" This reverts commit f7cd5c3fbdab83716dfb616bbe6efeddf82b0d49. --- tests/sentry/seer/migrations/__init__.py | 0 ...est_0009_genericize_night_shift_results.py | 67 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/sentry/seer/migrations/__init__.py create mode 100644 tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py diff --git a/tests/sentry/seer/migrations/__init__.py b/tests/sentry/seer/migrations/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py new file mode 100644 index 00000000000000..de4ec73ee5a6d1 --- /dev/null +++ b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py @@ -0,0 +1,67 @@ +from django.db.migrations.state import StateApps + +from sentry.testutils.cases import TestMigrations + + +class GenericizeNightShiftResultsMigrationTest(TestMigrations): + migrate_from = "0008_add_seer_run_models" + migrate_to = "0009_genericize_night_shift_results" + app = "seer" + + def setup_initial_state(self) -> None: + self.group = self.create_group() + + def setup_before_migration(self, apps: StateApps) -> None: + SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") + SeerNightShiftRunIssue = apps.get_model("seer", "SeerNightShiftRunIssue") + + run = SeerNightShiftRun.objects.create( + organization_id=self.organization.id, + triage_strategy="agentic_triage", + ) + autofix_row = SeerNightShiftRunIssue.objects.create( + run_id=run.id, + group_id=self.group.id, + action="autofix", + seer_run_id="seer-1", + ) + root_cause_row = SeerNightShiftRunIssue.objects.create( + run_id=run.id, + group_id=self.group.id, + action="root_cause_only", + seer_run_id="seer-2", + ) + self.run_id = run.id + self.autofix_row_id = autofix_row.id + self.root_cause_row_id = root_cause_row.id + + # A second run that recorded a failure on the legacy error_message + # column, to verify the per-row error_message backfill into extras. + self.failed_error_message = "No Seer quota available" + failed_run = SeerNightShiftRun.objects.create( + organization_id=self.organization.id, + triage_strategy="agentic_triage", + error_message=self.failed_error_message, + ) + self.failed_run_id = failed_run.id + + def test(self) -> None: + from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult + + autofix_row = SeerNightShiftRunResult.objects.get(id=self.autofix_row_id) + assert autofix_row.kind == "agentic_triage" + assert autofix_row.extras == {"action": "autofix"} + assert autofix_row.seer_run_id == "seer-1" + + root_cause_row = SeerNightShiftRunResult.objects.get(id=self.root_cause_row_id) + assert root_cause_row.kind == "agentic_triage" + assert root_cause_row.extras == {"action": "root_cause_only"} + assert root_cause_row.seer_run_id == "seer-2" + + # Run with no error_message keeps an empty (or near-empty) extras. + ok_run = SeerNightShiftRun.objects.get(id=self.run_id) + assert "error_message" not in (ok_run.extras or {}) + + # Run with a recorded error has it preserved in extras. + failed_run = SeerNightShiftRun.objects.get(id=self.failed_run_id) + assert failed_run.extras["error_message"] == self.failed_error_message From c8031318418adac494097acf0f70948332f32b31 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 21:27:20 -0400 Subject: [PATCH 08/11] fix(seer): Restore unrelated SeerNightShiftRun docstring change Drive-by edit from the original PR1 commit. No reason for it. --- src/sentry/seer/models/night_shift.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/models/night_shift.py b/src/sentry/seer/models/night_shift.py index 04111a82df301b..b1304990e3aa4d 100644 --- a/src/sentry/seer/models/night_shift.py +++ b/src/sentry/seer/models/night_shift.py @@ -13,7 +13,10 @@ class NightShiftRunResultKind(models.TextChoices): @cell_silo_model class SeerNightShiftRun(DefaultFieldsModel): - """One row per night shift invocation per organization.""" + """ + Records each night shift invocation for an organization. + One row is created per org each time run_night_shift_for_org executes. + """ __relocation_scope__ = RelocationScope.Excluded From 275d6134fc225cb10096fcf1f9ef16eb91e529e6 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 4 May 2026 22:06:09 -0400 Subject: [PATCH 09/11] ref(seer): Drop legacy night-shift columns and kind db_default Follow-up to the schema-genericization PR. The previous PR moved action, triage_strategy, and error_message to MOVE_TO_PENDING and left a db_default on the new kind column to keep mid-rolling-deploy INSERTs from old replicas valid. Now that the previous PR is fully deployed, this PR: - SafeRemoveField(DELETE) for action, triage_strategy, error_message to physically drop the columns from Postgres. - Drops the db_default on kind so callers must specify it explicitly (already the case in all live code). --- migrations_lockfile.txt | 2 +- .../migrations/0010_drop_legacy_columns.py | 50 +++++++++++++++++++ src/sentry/seer/models/night_shift.py | 4 +- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/sentry/seer/migrations/0010_drop_legacy_columns.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index ce1757e1b7ca52..2e95b43f1aa1e6 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ releases: 0004_cleanup_failed_safe_deletes replays: 0007_organizationmember_replay_access -seer: 0009_genericize_night_shift_results +seer: 0010_drop_legacy_columns sentry: 1080_backfill_deprecated_dashboard_widget_display_types diff --git a/src/sentry/seer/migrations/0010_drop_legacy_columns.py b/src/sentry/seer/migrations/0010_drop_legacy_columns.py new file mode 100644 index 00000000000000..6d25006d880120 --- /dev/null +++ b/src/sentry/seer/migrations/0010_drop_legacy_columns.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.12 on 2026-05-05 02:03 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.fields import SafeRemoveField +from sentry.new_migrations.monkey.state import DeletionAction + + +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 = [ + ("seer", "0009_genericize_night_shift_results"), + ] + + operations = [ + SafeRemoveField( + model_name="seernightshiftrunresult", + name="action", + deletion_action=DeletionAction.DELETE, + ), + SafeRemoveField( + model_name="seernightshiftrun", + name="triage_strategy", + deletion_action=DeletionAction.DELETE, + ), + SafeRemoveField( + model_name="seernightshiftrun", + name="error_message", + deletion_action=DeletionAction.DELETE, + ), + migrations.AlterField( + model_name="seernightshiftrunresult", + name="kind", + field=models.CharField(max_length=256), + ), + ] diff --git a/src/sentry/seer/models/night_shift.py b/src/sentry/seer/models/night_shift.py index b1304990e3aa4d..dd4c8830651c56 100644 --- a/src/sentry/seer/models/night_shift.py +++ b/src/sentry/seer/models/night_shift.py @@ -43,9 +43,7 @@ class SeerNightShiftRunResult(DefaultFieldsModel): run = FlexibleForeignKey( "seer.SeerNightShiftRun", on_delete=models.CASCADE, related_name="results" ) - kind = models.CharField( - max_length=256, db_default="agentic_triage", choices=NightShiftRunResultKind.choices - ) + kind = models.CharField(max_length=256, choices=NightShiftRunResultKind.choices) group = FlexibleForeignKey( "sentry.Group", on_delete=models.CASCADE, db_constraint=False, null=True ) From 21c80138e13e19ca4d304f1860d27265c191b99c Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 5 May 2026 11:12:05 -0400 Subject: [PATCH 10/11] fix(seer): Bump fail-slow budget on 0009 migration test The test passes but exceeds the global 120s budget because Django's migration framework has to roll back/forward 12 operations (including a CONCURRENTLY index) every time. CI attributes this work to the "call" phase, so pytest-fail-slow trips. Lifting the per-test budget to 4m gives stable headroom; the cost is fundamentally in the framework, not the test logic. --- .../migrations/test_0009_genericize_night_shift_results.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py index de4ec73ee5a6d1..4f63efcd72a29d 100644 --- a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py +++ b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py @@ -1,8 +1,14 @@ +import pytest from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations +# 0009 has 12 operations including a CONCURRENTLY index, so the migration +# round-trip TestMigrations does on every run takes longer than the global +# 120s fail-slow budget. The test itself is fast — the cost is Django's +# migration framework. +@pytest.mark.fail_slow("4m") class GenericizeNightShiftResultsMigrationTest(TestMigrations): migrate_from = "0008_add_seer_run_models" migrate_to = "0009_genericize_night_shift_results" From 8ab1c651403298050959be4cbe36bce9b7faed11 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 5 May 2026 12:32:53 -0400 Subject: [PATCH 11/11] ref(seer): Delete 0009 migration integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration shipped, the data backfilled correctly in prod, and the test was the only one in the codebase needing a fail_slow override — 130s of CI time on every backend PR for marginal post-merge value. Squash will eventually elide the migration anyway (it's marked elidable=True). Cleaner to remove now along with the column-deletion migration this PR ships. --- tests/sentry/seer/migrations/__init__.py | 0 ...est_0009_genericize_night_shift_results.py | 73 ------------------- 2 files changed, 73 deletions(-) delete mode 100644 tests/sentry/seer/migrations/__init__.py delete mode 100644 tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py diff --git a/tests/sentry/seer/migrations/__init__.py b/tests/sentry/seer/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py b/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py deleted file mode 100644 index 4f63efcd72a29d..00000000000000 --- a/tests/sentry/seer/migrations/test_0009_genericize_night_shift_results.py +++ /dev/null @@ -1,73 +0,0 @@ -import pytest -from django.db.migrations.state import StateApps - -from sentry.testutils.cases import TestMigrations - - -# 0009 has 12 operations including a CONCURRENTLY index, so the migration -# round-trip TestMigrations does on every run takes longer than the global -# 120s fail-slow budget. The test itself is fast — the cost is Django's -# migration framework. -@pytest.mark.fail_slow("4m") -class GenericizeNightShiftResultsMigrationTest(TestMigrations): - migrate_from = "0008_add_seer_run_models" - migrate_to = "0009_genericize_night_shift_results" - app = "seer" - - def setup_initial_state(self) -> None: - self.group = self.create_group() - - def setup_before_migration(self, apps: StateApps) -> None: - SeerNightShiftRun = apps.get_model("seer", "SeerNightShiftRun") - SeerNightShiftRunIssue = apps.get_model("seer", "SeerNightShiftRunIssue") - - run = SeerNightShiftRun.objects.create( - organization_id=self.organization.id, - triage_strategy="agentic_triage", - ) - autofix_row = SeerNightShiftRunIssue.objects.create( - run_id=run.id, - group_id=self.group.id, - action="autofix", - seer_run_id="seer-1", - ) - root_cause_row = SeerNightShiftRunIssue.objects.create( - run_id=run.id, - group_id=self.group.id, - action="root_cause_only", - seer_run_id="seer-2", - ) - self.run_id = run.id - self.autofix_row_id = autofix_row.id - self.root_cause_row_id = root_cause_row.id - - # A second run that recorded a failure on the legacy error_message - # column, to verify the per-row error_message backfill into extras. - self.failed_error_message = "No Seer quota available" - failed_run = SeerNightShiftRun.objects.create( - organization_id=self.organization.id, - triage_strategy="agentic_triage", - error_message=self.failed_error_message, - ) - self.failed_run_id = failed_run.id - - def test(self) -> None: - from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult - - autofix_row = SeerNightShiftRunResult.objects.get(id=self.autofix_row_id) - assert autofix_row.kind == "agentic_triage" - assert autofix_row.extras == {"action": "autofix"} - assert autofix_row.seer_run_id == "seer-1" - - root_cause_row = SeerNightShiftRunResult.objects.get(id=self.root_cause_row_id) - assert root_cause_row.kind == "agentic_triage" - assert root_cause_row.extras == {"action": "root_cause_only"} - assert root_cause_row.seer_run_id == "seer-2" - - # Run with no error_message keeps an empty (or near-empty) extras. - ok_run = SeerNightShiftRun.objects.get(id=self.run_id) - assert "error_message" not in (ok_run.extras or {}) - - # Run with a recorded error has it preserved in extras. - failed_run = SeerNightShiftRun.objects.get(id=self.failed_run_id) - assert failed_run.extras["error_message"] == self.failed_error_message