From c7163ae97834f62970a5ac06ca26442e24a13b25 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Thu, 16 Jul 2026 11:12:25 +0900 Subject: [PATCH 1/6] fix(BA-6913): declare indexes that only the migrations created The schema is defined twice: a fresh install builds tables from the model metadata via `mgr schema oneshot` and stamps head without running a single migration, while an existing install replays the migration files. Nothing checks that the two agree, and for three indexes they did not -- the migrations create them, the models never declared them, so only migrated databases ever had them. The worst of the three covers role_invitations.invitee_user_id, whose FK to users is ON DELETE CASCADE: without it a fresh install scans the table on every user deletion. uq_role_invitations_active cannot stand in for it because it is partial (state != 'accepted') and so misses exactly the rows a cascade has to find. Declare all three on the models, and add a migration so databases already deployed by oneshot pick them up; IF NOT EXISTS keeps it a no-op where they are already present. Also drop the redundant drop_index calls that made this visible: the following drop_column/drop_table removes the index anyway, so naming it first only broke downgrades on installs that never had it. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...f8_add_preset_category_description_rank.py | 4 +- ...ad7acfe8aa1c_add_role_invitations_table.py | 4 +- ...kfill_indexes_missing_on_fresh_installs.py | 44 +++++++++++++++++++ ...b4e7f1a2c3d5_add_creator_id_to_vfolders.py | 3 +- .../models/prometheus_query_preset/row.py | 1 + .../manager/models/role_invitation/row.py | 4 ++ src/ai/backend/manager/models/vfolder/row.py | 4 +- 7 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py 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_backfill_indexes_missing_on_fresh_installs.py b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py new file mode 100644 index 00000000000..35223e34f97 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py @@ -0,0 +1,44 @@ +"""backfill indexes missing on fresh installs + +Three migrations create an index that the matching model never declared, so the +index only ever existed on databases built by replaying migrations. Databases +built by `mgr schema oneshot` -- which creates tables from the model metadata and +stamps head -- never got them. The models now declare all three, which covers new +installs; this migration heals the ones already out there. + +IF NOT EXISTS keeps it a no-op on migrated databases, which already have them. + +Revision ID: b2f7c1a94e30 +Revises: aa27f1d5cd35 +Create Date: 2026-07-16 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b2f7c1a94e30" +down_revision = "aa27f1d5cd35" +# 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_vfolders_creator_id ON vfolders (creator_id)") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_prometheus_query_presets_category_id" + " ON prometheus_query_presets (category_id)" + ) + + +def downgrade() -> None: + # Intentionally a no-op. These indexes are part of the schema the models + # declare, so dropping them here would recreate the very drift this + # migration exists to remove -- and would strand the revisions that create + # them, whose downgrades expect them to be present. + 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..420d6713bc0 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 @@ -38,5 +38,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", diff --git a/src/ai/backend/manager/models/vfolder/row.py b/src/ai/backend/manager/models/vfolder/row.py index 57d18a690d3..a5158b02df8 100644 --- a/src/ai/backend/manager/models/vfolder/row.py +++ b/src/ai/backend/manager/models/vfolder/row.py @@ -331,7 +331,9 @@ class VFolderRow(Base): # type: ignore[misc] ) # creator is always set to the user who created vfolder (regardless user/project types) creator: Mapped[str | None] = mapped_column("creator", sa.String(length=128), nullable=True) - creator_id: Mapped[uuid.UUID | None] = mapped_column("creator_id", GUID, nullable=True) + creator_id: Mapped[uuid.UUID | None] = mapped_column( + "creator_id", GUID, nullable=True, index=True + ) # unmanaged vfolder represents the host-side absolute path instead of storage-based path. unmanaged_path: Mapped[str | None] = mapped_column( "unmanaged_path", sa.String(length=512), nullable=True From ab901a9fc0339091908df12d34051a3b3418e46f Mon Sep 17 00:00:00 2001 From: Gyubong Date: Thu, 16 Jul 2026 11:17:19 +0900 Subject: [PATCH 2/6] docs: add news fragment Co-Authored-By: Claude Opus 4.8 (1M context) --- changes/12907.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/12907.fix.md diff --git a/changes/12907.fix.md b/changes/12907.fix.md new file mode 100644 index 00000000000..d7087e89f4d --- /dev/null +++ b/changes/12907.fix.md @@ -0,0 +1 @@ +Create the role invitation, vfolder creator, and Prometheus preset category indexes on fresh installs, which previously only existed on migrated databases From f4e07c9c128691fcf3e87d317fe649afdf90d898 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Thu, 16 Jul 2026 11:28:48 +0900 Subject: [PATCH 3/6] fix(BA-6913): drop the unjustified vfolders.creator_id index declaration Converging the two schema paths only makes sense in the direction the index earns. role_invitations.invitee_user_id and prometheus_query_presets.category_id each back a foreign key whose parent-side delete (CASCADE / SET NULL) has to find the referencing rows, so declaring them costs a write and saves a scan. vfolders.creator_id has neither: no foreign key, and nothing in the tree filters, joins, or orders by it -- it is only ever written and selected. Declaring it would add write cost to every vfolder for no reader, so leave the model as it was. Whether the migration should have created that index at all is a separate question from this one. The redundant drop_index removal in b4e7f1a2c3d5 stays: drop_column takes the index with it whether or not the index is there, so that downgrade passes on a fresh install either way. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...c1a94e30_backfill_indexes_missing_on_fresh_installs.py | 8 +++++--- src/ai/backend/manager/models/vfolder/row.py | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py index 35223e34f97..24502bab6a4 100644 --- a/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py +++ b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py @@ -1,11 +1,14 @@ """backfill indexes missing on fresh installs -Three migrations create an index that the matching model never declared, so the +Two migrations create an index that the matching model never declared, so the index only ever existed on databases built by replaying migrations. Databases built by `mgr schema oneshot` -- which creates tables from the model metadata and -stamps head -- never got them. The models now declare all three, which covers new +stamps head -- never got them. The models now declare both, which covers new installs; this migration heals the ones already out there. +Both cover a foreign key column whose parent-side delete has to find the +referencing rows, so the index earns its write cost. + IF NOT EXISTS keeps it a no-op on migrated databases, which already have them. Revision ID: b2f7c1a94e30 @@ -29,7 +32,6 @@ def upgrade() -> None: "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_vfolders_creator_id ON vfolders (creator_id)") op.execute( "CREATE INDEX IF NOT EXISTS ix_prometheus_query_presets_category_id" " ON prometheus_query_presets (category_id)" diff --git a/src/ai/backend/manager/models/vfolder/row.py b/src/ai/backend/manager/models/vfolder/row.py index a5158b02df8..57d18a690d3 100644 --- a/src/ai/backend/manager/models/vfolder/row.py +++ b/src/ai/backend/manager/models/vfolder/row.py @@ -331,9 +331,7 @@ class VFolderRow(Base): # type: ignore[misc] ) # creator is always set to the user who created vfolder (regardless user/project types) creator: Mapped[str | None] = mapped_column("creator", sa.String(length=128), nullable=True) - creator_id: Mapped[uuid.UUID | None] = mapped_column( - "creator_id", GUID, nullable=True, index=True - ) + creator_id: Mapped[uuid.UUID | None] = mapped_column("creator_id", GUID, nullable=True) # unmanaged vfolder represents the host-side absolute path instead of storage-based path. unmanaged_path: Mapped[str | None] = mapped_column( "unmanaged_path", sa.String(length=512), nullable=True From f1c1002a738f5eff5d8488b91cb4ba2d86b2e33c Mon Sep 17 00:00:00 2001 From: Gyubong Date: Thu, 16 Jul 2026 11:39:24 +0900 Subject: [PATCH 4/6] fix(BA-6913): stop creating the vfolders.creator_id index nobody reads Converging the two schema paths only makes sense in the direction the index earns. vfolders.creator_id carries no foreign key and is never filtered, joined, or ordered by anywhere in the tree -- it is only written and selected -- so the index it got in b4e7f1a2c3d5 costs a write on every vfolder and saves nothing. Rather than declare it on the model to match, stop creating it, and drop it from the databases that already ran that revision. The reconcile migration now moves each index in whichever direction it earns: it creates the two that back a foreign key whose parent-side delete must find the referencing rows, and drops this one. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...kfill_indexes_missing_on_fresh_installs.py | 46 ---------------- ...oncile_index_drift_across_install_paths.py | 53 +++++++++++++++++++ ...b4e7f1a2c3d5_add_creator_id_to_vfolders.py | 6 --- 3 files changed, 53 insertions(+), 52 deletions(-) delete mode 100644 src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py create mode 100644 src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_reconcile_index_drift_across_install_paths.py diff --git a/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py b/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py deleted file mode 100644 index 24502bab6a4..00000000000 --- a/src/ai/backend/manager/models/alembic/versions/b2f7c1a94e30_backfill_indexes_missing_on_fresh_installs.py +++ /dev/null @@ -1,46 +0,0 @@ -"""backfill indexes missing on fresh installs - -Two migrations create an index that the matching model never declared, so the -index only ever existed on databases built by replaying migrations. Databases -built by `mgr schema oneshot` -- which creates tables from the model metadata and -stamps head -- never got them. The models now declare both, which covers new -installs; this migration heals the ones already out there. - -Both cover a foreign key column whose parent-side delete has to find the -referencing rows, so the index earns its write cost. - -IF NOT EXISTS keeps it a no-op on migrated databases, which already have them. - -Revision ID: b2f7c1a94e30 -Revises: aa27f1d5cd35 -Create Date: 2026-07-16 - -""" - -from alembic import op - -# revision identifiers, used by Alembic. -revision = "b2f7c1a94e30" -down_revision = "aa27f1d5cd35" -# 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)" - ) - - -def downgrade() -> None: - # Intentionally a no-op. These indexes are part of the schema the models - # declare, so dropping them here would recreate the very drift this - # migration exists to remove -- and would strand the revisions that create - # them, whose downgrades expect them to be present. - pass 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..2aeaa2ee78c --- /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: aa27f1d5cd35 +Create Date: 2026-07-16 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b2f7c1a94e30" +down_revision = "aa27f1d5cd35" +# 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 420d6713bc0..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( From b5d4e76094863d06d5a260d9299c78efff27b94d Mon Sep 17 00:00:00 2001 From: Gyubong Date: Thu, 16 Jul 2026 11:41:30 +0900 Subject: [PATCH 5/6] docs: update news fragment Co-Authored-By: Claude Opus 4.8 (1M context) --- changes/12907.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/12907.fix.md b/changes/12907.fix.md index d7087e89f4d..8b42cc7d582 100644 --- a/changes/12907.fix.md +++ b/changes/12907.fix.md @@ -1 +1 @@ -Create the role invitation, vfolder creator, and Prometheus preset category indexes on fresh installs, which previously only existed on migrated databases +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 From e972044955618f51b379ecddd2eb6d8a02406033 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 20 Jul 2026 13:39:17 +0900 Subject: [PATCH 6/6] fix(BA-6913): rebase the index-drift migration onto the current head --- ...b2f7c1a94e30_reconcile_index_drift_across_install_paths.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 2aeaa2ee78c..6f5b261d945 100644 --- 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 @@ -20,7 +20,7 @@ agrees. Revision ID: b2f7c1a94e30 -Revises: aa27f1d5cd35 +Revises: 3f9a1c7b2e04 Create Date: 2026-07-16 """ @@ -29,7 +29,7 @@ # revision identifiers, used by Alembic. revision = "b2f7c1a94e30" -down_revision = "aa27f1d5cd35" +down_revision = "3f9a1c7b2e04" # Part of: NEXT_RELEASE_VERSION branch_labels = None depends_on = None