Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/api-server-invariants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ jobs:
# 인스타 트렌드 불릿, assets 프로젝트 테이블)를 참고 신호로 read-only 조회.
# 엔티티 파일은 정의 + "assets_db 로만 조회" 주석. operations 아닌 assets 에
# 두는 결정(외부 파이프라인 산출물 = raw_posts 와 동일 성격).
#
# domains/catalog_entities/
# — GET /brands/{id}/solutions (#886). list_brand_solutions 가
# solutions_service::list_solutions_by_brand_id 에 위임 — prod solutions 조회 +
# assets affiliate_catalog_items/variants 로 market-aware affiliate URL·taxonomy
# resolve (domains/solutions/ 와 동일 cross-pool 패턴). looks/artist 는 prod-only.
- name: Guard dual-DB pool selection — assets_db allowlist (#717)
run: |
set -euo pipefail
Expand Down Expand Up @@ -178,6 +184,7 @@ jobs:
| grep -v "^${SRC}/domains/admin/solutions\.rs:" \
| grep -v "^${SRC}/domains/catalog/service\.rs:" \
| grep -v "^${SRC}/domains/catalog/handlers\.rs:" \
| grep -v "^${SRC}/domains/catalog_entities/" \
| grep -v "^${SRC}/domains/posts/service\.rs:" \
| grep -v "^${SRC}/domains/posts/handlers\.rs:" \
| grep -v "^${SRC}/domains/posts/looksets\.rs:" \
Expand Down
280 changes: 280 additions & 0 deletions packages/ai-server/scripts/backfill_artist_profile_image_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
# pyright: reportMissingImports=false
"""Copy instagram_accounts.profile_image_url → artists.profile_image_url (1회성).

artists.profile_image_url 이 비어 있고, 연결된 Instagram 계정에만 이미지가 있을 때
primary_instagram_account_id 우선, 없으면 artist_id 링크 중 최신 row 를 사용한다.

Usage:
cd packages/ai-server

uv run python scripts/backfill_artist_profile_image_url.py \\
--db-env ../../.env.backend.prod --dry-run

uv run python scripts/backfill_artist_profile_image_url.py \\
--db-env ../../.env.backend.prod --apply
"""

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger("backfill-artist-profile-image")


def _parse_env_file(path: Path) -> dict[str, str]:
if not path.exists():
raise FileNotFoundError(f"env file not found: {path}")
out: dict[str, str] = {}
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
if not key:
continue
for marker in (" #", "\t#", " #"):
idx = val.find(marker)
if idx != -1:
val = val[:idx]
break
out[key] = val.strip().strip('"').strip("'")
return out


def _resolve_env(path: Optional[str], keys: list[str]) -> dict[str, str]:
if path:
parsed = _parse_env_file(Path(path).expanduser().resolve())
return {k: parsed[k] for k in keys if k in parsed}
return {k: os.environ[k] for k in keys if k in os.environ}


def _mask_host(db_url: str) -> str:
try:
return db_url.split("@", 1)[1]
except IndexError:
return "<unparseable>"


def _is_local(db_url: str) -> bool:
return _mask_host(db_url).startswith(("127.0.0.1", "localhost", "::1"))


def _is_supabase_pooler(db_url: str) -> bool:
return "pooler.supabase.com" in db_url


def _prefer_transaction_pooler(db_url: str, *, session_pooler: bool) -> str:
if session_pooler or not _is_supabase_pooler(db_url):
return db_url
if ":5432/" in db_url:
return db_url.replace(":5432/", ":6543/", 1)
return db_url


async def _connect(
db_url: str,
*,
session_pooler: bool = False,
connect_retries: int = 4,
):
import asyncpg

resolved = _prefer_transaction_pooler(db_url, session_pooler=session_pooler)
ssl = None if _is_local(resolved) else "require"
kwargs: dict[str, Any] = {}
if _is_supabase_pooler(resolved):
kwargs["statement_cache_size"] = 0

delay = 1.0
last_exc: Optional[Exception] = None
for attempt in range(connect_retries + 1):
try:
conn = await asyncpg.connect(resolved, ssl=ssl, **kwargs)
if resolved != db_url:
logger.info(
"using transaction pooler %s (session :5432 → :6543)",
_mask_host(resolved),
)
return conn
except Exception as exc: # noqa: BLE001
last_exc = exc
msg = str(exc).lower()
pool_full = "max clients" in msg or "too many clients" in msg
if attempt < connect_retries and (pool_full or "timeout" in msg):
logger.warning("connect failed (attempt %s): %s", attempt + 1, exc)
await asyncio.sleep(delay)
delay = min(delay * 2, 8.0)
continue
if pool_full:
logger.error(
"Supabase session pool exhausted. Stop `just local-be` or use --db-url."
)
raise
raise last_exc # type: ignore[misc]


_COUNT_SQL = """
SELECT COUNT(*)::bigint
FROM public.artists a
LEFT JOIN public.instagram_accounts primary_ia
ON primary_ia.id = a.primary_instagram_account_id
WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '')
AND COALESCE(
NULLIF(btrim(primary_ia.profile_image_url), ''),
(
SELECT NULLIF(btrim(ia2.profile_image_url), '')
FROM public.instagram_accounts ia2
WHERE ia2.artist_id = a.id
AND ia2.profile_image_url IS NOT NULL
AND btrim(ia2.profile_image_url) <> ''
ORDER BY ia2.updated_at DESC
LIMIT 1
)
) IS NOT NULL
"""

_PREVIEW_SQL = """
SELECT
a.id,
COALESCE(a.name_ko, a.name_en, a.name ->> 'ko', a.name ->> 'en') AS name,
COALESCE(
NULLIF(btrim(primary_ia.profile_image_url), ''),
(
SELECT NULLIF(btrim(ia2.profile_image_url), '')
FROM public.instagram_accounts ia2
WHERE ia2.artist_id = a.id
AND ia2.profile_image_url IS NOT NULL
AND btrim(ia2.profile_image_url) <> ''
ORDER BY ia2.updated_at DESC
LIMIT 1
)
) AS source_url,
CASE
WHEN primary_ia.profile_image_url IS NOT NULL
AND btrim(primary_ia.profile_image_url) <> ''
THEN 'primary_instagram_account_id'
ELSE 'artist_id_link'
END AS source
FROM public.artists a
LEFT JOIN public.instagram_accounts primary_ia
ON primary_ia.id = a.primary_instagram_account_id
WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '')
AND COALESCE(
NULLIF(btrim(primary_ia.profile_image_url), ''),
(
SELECT NULLIF(btrim(ia2.profile_image_url), '')
FROM public.instagram_accounts ia2
WHERE ia2.artist_id = a.id
AND ia2.profile_image_url IS NOT NULL
AND btrim(ia2.profile_image_url) <> ''
ORDER BY ia2.updated_at DESC
LIMIT 1
)
) IS NOT NULL
ORDER BY a.updated_at DESC
LIMIT $1
"""

_UPDATE_SQL = """
UPDATE public.artists a
SET profile_image_url = src.url,
updated_at = NOW()
FROM (
SELECT
a.id,
COALESCE(
NULLIF(btrim(primary_ia.profile_image_url), ''),
(
SELECT NULLIF(btrim(ia2.profile_image_url), '')
FROM public.instagram_accounts ia2
WHERE ia2.artist_id = a.id
AND ia2.profile_image_url IS NOT NULL
AND btrim(ia2.profile_image_url) <> ''
ORDER BY ia2.updated_at DESC
LIMIT 1
)
) AS url
FROM public.artists a
LEFT JOIN public.instagram_accounts primary_ia
ON primary_ia.id = a.primary_instagram_account_id
WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '')
) src
WHERE a.id = src.id
AND src.url IS NOT NULL
"""


async def run(args: argparse.Namespace) -> int:
db_cfg = _resolve_env(args.db_env, ["OPERATION_DATABASE_URL"])
db_url = (
args.db_url
or db_cfg.get("OPERATION_DATABASE_URL", "")
or os.environ.get("OPERATION_DATABASE_URL", "")
)
if not db_url:
logger.error("OPERATION_DATABASE_URL missing — pass --db-env or --db-url")
return 2

if not args.apply and not args.dry_run:
logger.error("Specify --dry-run or --apply")
return 2

logger.info("db host: %s", _mask_host(db_url))
conn = await _connect(db_url, session_pooler=args.session_pooler)
try:
total = await conn.fetchval(_COUNT_SQL)
logger.info("artists to backfill: %d", total)
if total == 0:
return 0

preview_rows = await conn.fetch(_PREVIEW_SQL, args.preview_limit)
for row in preview_rows:
logger.info(
"%s (%s) ← %s | %s",
row["name"] or row["id"],
str(row["id"])[:8],
row["source"],
(row["source_url"] or "")[:80],
)
if total > len(preview_rows):
logger.info("... and %d more", total - len(preview_rows))

if args.dry_run:
logger.info("dry-run complete — no rows updated")
return 0

result = await conn.execute(_UPDATE_SQL)
logger.info("apply complete: %s", result)
remaining = await conn.fetchval(_COUNT_SQL)
logger.info("remaining artists needing backfill: %d", remaining)
return 0
finally:
await conn.close()


def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db-env", default="", help="Env file with OPERATION_DATABASE_URL")
parser.add_argument("--db-url", default="", help="OPERATION_DATABASE_URL override")
parser.add_argument(
"--session-pooler",
action="store_true",
help="Use Supabase session pooler (:5432); default is transaction pooler (:6543)",
)
parser.add_argument("--preview-limit", type=int, default=10, help="Sample rows in logs")
parser.add_argument("--dry-run", action="store_true", help="Preview only")
parser.add_argument("--apply", action="store_true", help="Write to OPERATION_DATABASE_URL")
raise SystemExit(asyncio.run(run(parser.parse_args())))


if __name__ == "__main__":
main()
Loading
Loading