"""image_prediction table (DDL only — backfill runs as a background task) Normalizes the per-image tagger predictions out of the JSON blob into a queryable table (#768). This migration creates ONLY the table + indexes — it is pure DDL and commits instantly, so web boots immediately. The data backfill from the existing image_record.tagger_predictions JSON is deliberately NOT done here. Doing it inline made the whole migration one transaction over the ~100 GB TOAST: nothing committed until the very end, it was invisible/unmonitorable mid-run, and an early MATERIALIZED-CTE form spilled the full 100 GB to temp. Instead the backfill is the backend.app.tasks.admin.backfill_image_predictions_task — batched by id window, committed per chunk (visible progress + resumable), idempotent (ON CONFLICT DO NOTHING). Trigger it from Settings → Maintenance once web is up. The old image_record.tagger_predictions column is left in place (vestigial) and dropped in a follow-up once the backfill + code cutover are verified — dropping it needs an ACCESS EXCLUSIVE lock on the hot image_record table (the 0044 lock class), so it's deferred to a quiesced-worker window. Revision ID: 0045 Revises: 0044 Create Date: 2026-06-10 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0045" down_revision: Union[str, None] = "0044" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "image_prediction", sa.Column("id", sa.Integer(), primary_key=True), sa.Column( "image_record_id", sa.Integer(), sa.ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False, ), sa.Column("raw_name", sa.String(length=255), nullable=False), sa.Column("category", sa.String(length=64), nullable=False), sa.Column("score", sa.Float(), nullable=False), sa.UniqueConstraint( "image_record_id", "raw_name", name="image_raw_name", ), ) op.create_index( "ix_image_prediction_image", "image_prediction", ["image_record_id"], ) op.create_index( "ix_image_prediction_name_score", "image_prediction", ["raw_name", "score"], ) # No data backfill here — see the module docstring. The one-time copy from # image_record.tagger_predictions runs as backfill_image_predictions_task # (batched, resumable, idempotent), kept out of this transaction so web boots # without waiting on a ~100 GB pass. def downgrade() -> None: op.drop_index("ix_image_prediction_name_score", "image_prediction") op.drop_index("ix_image_prediction_image", "image_prediction") op.drop_table("image_prediction")