Files
FabledCurator/alembic/versions/0045_image_prediction_table.py
T
bvandeusen 75eab188c8
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m12s
fix(migration): 0045 backfill filters to >= store floor (supersedes #764 prune)
The #764 in-place prune (rewrite tagger_predictions to >=0.70) is too slow on
100 GB of TOAST and fails at its soft limit (interrupts a query mid-flight ->
'another command is already in progress'). #768 supersedes it: extract only
the >=floor predictions into image_prediction via this set-based backfill,
then drop the column (step 3) — reading 100 GB once + writing ~840k small rows
beats rewriting 100 GB in place.

So this backfill no longer assumes the prune ran: it filters by
ml_settings.tagger_store_floor (default 0.70) itself, handling the full or
partially-pruned JSON identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:37:38 -04:00

83 lines
3.2 KiB
Python

"""image_prediction table + backfill from image_record.tagger_predictions
Normalizes the per-image tagger predictions out of the JSON blob into a
queryable table (#768). Backfills from the existing JSON in one set-based
INSERT…SELECT over json_each — fast because the #764 prune already shrank
each row to its >=0.70 entries. The old image_record.tagger_predictions
column is left in place here (vestigial) and dropped in a follow-up once the
code cutover is 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"],
)
# Backfill from the JSON blob. json_each expands {name: {category,
# confidence}} into one row per prediction. category defaults to 'general'
# to mirror the suggestion read path; rows with no confidence are skipped.
# Filter to >= the store floor (ml_settings.tagger_store_floor, default
# 0.70) right here so this is self-sufficient — it does NOT depend on the
# #764 prune having run, and extracting only the >=floor tail keeps
# image_prediction small (~tens of rows/image) even from the full JSON.
op.execute(
"""
INSERT INTO image_prediction (image_record_id, raw_name, category, score)
SELECT ir.id,
je.key,
COALESCE(je.value ->> 'category', 'general'),
(je.value ->> 'confidence')::double precision
FROM image_record ir,
json_each(ir.tagger_predictions) je
WHERE ir.tagger_predictions IS NOT NULL
AND je.value ->> 'confidence' IS NOT NULL
AND (je.value ->> 'confidence')::double precision
>= COALESCE(
(SELECT tagger_store_floor FROM ml_settings WHERE id = 1),
0.70
)
ON CONFLICT (image_record_id, raw_name) DO NOTHING
"""
)
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")