diff --git a/changes/12907.fix.md b/changes/12907.fix.md new file mode 100644 index 00000000000..8b42cc7d582 --- /dev/null +++ b/changes/12907.fix.md @@ -0,0 +1 @@ +Reconcile indexes that differed between fresh installs and migrated databases, adding the two that back a foreign key and dropping the unused vfolder creator index diff --git a/src/ai/backend/manager/models/alembic/versions/a3b4c5d6e7f8_add_preset_category_description_rank.py b/src/ai/backend/manager/models/alembic/versions/a3b4c5d6e7f8_add_preset_category_description_rank.py index 883f876c24e..84af88c8a18 100644 --- a/src/ai/backend/manager/models/alembic/versions/a3b4c5d6e7f8_add_preset_category_description_rank.py +++ b/src/ai/backend/manager/models/alembic/versions/a3b4c5d6e7f8_add_preset_category_description_rank.py @@ -65,7 +65,9 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_index("ix_prometheus_query_presets_category_id", table_name="prometheus_query_presets") + # drop_column below takes the column's index and foreign key with it; + # dropping either by name first only breaks on installs that never had + # that name. op.drop_column("prometheus_query_presets", "category_id") op.drop_column("prometheus_query_presets", "rank") op.drop_column("prometheus_query_presets", "description") diff --git a/src/ai/backend/manager/models/alembic/versions/ad7acfe8aa1c_add_role_invitations_table.py b/src/ai/backend/manager/models/alembic/versions/ad7acfe8aa1c_add_role_invitations_table.py index 6526cc1ae53..bf3fe15e078 100644 --- a/src/ai/backend/manager/models/alembic/versions/ad7acfe8aa1c_add_role_invitations_table.py +++ b/src/ai/backend/manager/models/alembic/versions/ad7acfe8aa1c_add_role_invitations_table.py @@ -248,6 +248,6 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_index("uq_role_invitations_active", table_name="role_invitations") - op.drop_index("ix_role_invitations_invitee_user_id", table_name="role_invitations") + # drop_table takes the table's indexes with it; dropping them by name first + # only breaks on installs that never had them. op.drop_table("role_invitations") diff --git a/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_reconcile_index_drift_across_install_paths.py b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_reconcile_index_drift_across_install_paths.py new file mode 100644 index 00000000000..6f5b261d945 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_reconcile_index_drift_across_install_paths.py @@ -0,0 +1,53 @@ +"""reconcile index drift across install paths + +A fresh install builds tables from the model metadata (`mgr schema oneshot`) and +stamps head without running a migration; an existing install replays the +migration files. Three indexes were created by migrations but never declared on +the models, so they existed only on migrated databases. This migration makes +every database agree with the models, in whichever direction each index earns. + +Created here, because each backs a foreign key whose parent-side delete has to +find the referencing rows -- the index costs a write and saves a scan: + - ix_role_invitations_invitee_user_id (users delete cascades) + - ix_prometheus_query_presets_category_id (category delete sets null) + +Dropped here, because nothing reads it: vfolders.creator_id carries no foreign +key and is never filtered, joined, or ordered by -- it is only written and +selected. Its creation has been removed from b4e7f1a2c3d5, so this drop is what +clears it from databases that already ran that revision. + +The existence checks keep every statement a no-op where the database already +agrees. + +Revision ID: b2f7c1a94e30 +Revises: 3f9a1c7b2e04 +Create Date: 2026-07-16 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b2f7c1a94e30" +down_revision = "3f9a1c7b2e04" +# Part of: NEXT_RELEASE_VERSION +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "CREATE INDEX IF NOT EXISTS ix_role_invitations_invitee_user_id" + " ON role_invitations (invitee_user_id)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_prometheus_query_presets_category_id" + " ON prometheus_query_presets (category_id)" + ) + op.execute("DROP INDEX IF EXISTS ix_vfolders_creator_id") + + +def downgrade() -> None: + # Intentionally a no-op. This migration exists to make the two install paths + # agree with the models; undoing it would only put the drift back. + pass diff --git a/src/ai/backend/manager/models/alembic/versions/b4e7f1a2c3d5_add_creator_id_to_vfolders.py b/src/ai/backend/manager/models/alembic/versions/b4e7f1a2c3d5_add_creator_id_to_vfolders.py index bbc8f29f3f1..ce5072e36dd 100644 --- a/src/ai/backend/manager/models/alembic/versions/b4e7f1a2c3d5_add_creator_id_to_vfolders.py +++ b/src/ai/backend/manager/models/alembic/versions/b4e7f1a2c3d5_add_creator_id_to_vfolders.py @@ -23,12 +23,6 @@ def upgrade() -> None: "vfolders", sa.Column("creator_id", GUID, nullable=True), ) - op.create_index( - op.f("ix_vfolders_creator_id"), - "vfolders", - ["creator_id"], - unique=False, - ) # Backfill creator_id from users table by matching creator (email) → users.email op.execute( sa.text( @@ -38,5 +32,6 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_index(op.f("ix_vfolders_creator_id"), table_name="vfolders") + # drop_column takes the column's index with it; dropping it by name first + # only breaks on installs that never had it. op.drop_column("vfolders", "creator_id") diff --git a/src/ai/backend/manager/models/prometheus_query_preset/row.py b/src/ai/backend/manager/models/prometheus_query_preset/row.py index 983eda94283..4b525fc62fe 100644 --- a/src/ai/backend/manager/models/prometheus_query_preset/row.py +++ b/src/ai/backend/manager/models/prometheus_query_preset/row.py @@ -46,6 +46,7 @@ class PrometheusQueryPresetRow(LifecycleTimestampsMixin, Base): # type: ignore[ GUID, sa.ForeignKey("prometheus_query_preset_categories.id", ondelete="SET NULL"), nullable=True, + index=True, ) options: Mapped[PresetOptions] = mapped_column( "options", diff --git a/src/ai/backend/manager/models/role_invitation/row.py b/src/ai/backend/manager/models/role_invitation/row.py index 2f3f0f295d8..13ffdacc29b 100644 --- a/src/ai/backend/manager/models/role_invitation/row.py +++ b/src/ai/backend/manager/models/role_invitation/row.py @@ -39,6 +39,10 @@ class RoleInvitationRow(Base): # type: ignore[misc] GUID, sa.ForeignKey("users.uuid", onupdate="CASCADE", ondelete="CASCADE"), nullable=False, + # The ON DELETE CASCADE above has to find every row for a user, including + # accepted ones, so uq_role_invitations_active cannot serve it -- that + # index is partial (state != 'accepted'). + index=True, ) role_id: Mapped[uuid.UUID] = mapped_column( "role_id",