From 5b776a52fde6a82d7a27ef24e5bbaef83528a70a Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 04:58:15 +0000 Subject: [PATCH] test(infra): verify NPC selection migration recovery --- packages/infra/package.json | 1 + packages/infra/prisma/migrations/README.md | 61 +++++ .../verify-npc-selection-token-migration.sh | 256 ++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100755 packages/infra/scripts/verify-npc-selection-token-migration.sh diff --git a/packages/infra/package.json b/packages/infra/package.json index a16cde7..d6a984d 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -18,6 +18,7 @@ "prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma", "prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts", "prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma", + "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh", "prisma:db:push:game": "prisma db push --schema prisma/game.prisma", "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" }, diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index ae5ac06..98ba9cb 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -33,6 +33,67 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `city.trade` nullable, `city.trust` REAL - `auction_bid.meta` JSONB NOT NULL - `traffic_period`, `traffic_period_general`과 unique key +- `select_npc_token`, `select_npc_token_valid_until_idx` +- `general_user_id_key` 검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유 database나 Compose volume을 삭제하지 않습니다. + +## NPC selection 중복 owner preflight + +`20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면 +token table과 unique index를 만들기 전에 실패합니다. 실제 Prisma 실패 +metadata와 운영자 정리 뒤 recovery를 전용 tmpfs PostgreSQL에서 검증합니다. + +```sh +pnpm --filter @sammo-ts/infra verify:migration:npc-selection +``` + +검증기는 target 직전 migration 상태에 synthetic 중복 owner를 넣고 다음 +순서를 확인합니다. + +1. `migrate deploy`가 실패합니다. Prisma 7.2 CLI의 최상위 오류는 내부 + owner 진단 대신 `current transaction is aborted`로 표시됩니다. +2. migration transaction의 table/index DDL이 남지 않습니다. +3. target `_prisma_migrations`에는 `finished_at`, `rolled_back_at`, `logs`가 + 모두 NULL이고 `applied_steps_count=0`인 미완료 행이 하나 남습니다. +4. 재실행은 이 미완료 이력 때문에 P3009로 차단됩니다. +5. 중복을 정리하고 target을 `--rolled-back`으로 resolve한 뒤 deploy가 + 성공합니다. +6. 두 번째 deploy는 no-op이고 migration status가 clean입니다. + +검증기는 `postgres:18.4-bookworm` container의 데이터 경로를 tmpfs로 +mount하며 Docker volume을 만들지 않습니다. 임의 포트와 실행 고유 schema를 +사용하고 EXIT/HUP/INT/TERM에서 소유 label을 확인한 정확한 container만 +제거합니다. + +운영 복구에서는 중복 owner를 임의 삭제하지 말고 진단된 계정과 장수를 +확인해 주세요. CLI 오류가 일반 transaction 오류만 표시하면 다음 read-only +query로 대상을 확인합니다. + +```sql +SELECT + user_id, + count(*) AS owner_count, + string_agg(id::TEXT, ',' ORDER BY id) AS general_ids +FROM general +WHERE user_id IS NOT NULL +GROUP BY user_id +HAVING count(*) > 1 +ORDER BY user_id; +``` + +중복 원인을 정리한 뒤 다음처럼 실패한 target만 rolled-back으로 표시하고 +deploy를 다시 실행합니다. + +```sh +pnpm --filter @sammo-ts/infra exec prisma migrate resolve \ + --rolled-back 20260731000000_add_npc_selection_token \ + --schema prisma/game.prisma +pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game +``` + +다른 migration을 resolve하거나 중복을 정리하기 전에 resolve하지 말아 +주세요. 이 migration에는 성공 적용을 되돌리는 down migration이 없습니다. +성공 뒤 복구가 필요하면 사전 DB backup을 복원하거나 별도 forward +migration을 작성해 주세요. diff --git a/packages/infra/scripts/verify-npc-selection-token-migration.sh b/packages/infra/scripts/verify-npc-selection-token-migration.sh new file mode 100755 index 0000000..469fcbf --- /dev/null +++ b/packages/infra/scripts/verify-npc-selection-token-migration.sh @@ -0,0 +1,256 @@ +#!/bin/sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +package_dir="$(dirname "$script_dir")" +prisma_dir="$package_dir/prisma" +target_migration=20260731000000_add_npc_selection_token +task_label=devsam.core2026.npc-selection-migration-preflight +container_name="sammo-npc-migration-preflight-$(date +%s)-$$" +schema_name="npc_selection_migration_preflight_$(date +%s)_$$" +work_dir="$(mktemp -d /tmp/sammo-npc-migration-preflight.XXXXXX)" +container_created=0 + +case "$container_name" in + sammo-npc-migration-preflight-[0-9]*-[0-9]*) ;; + *) echo "unsafe migration preflight container name" >&2; exit 64 ;; +esac +case "$schema_name" in + npc_selection_migration_preflight_[0-9]*_[0-9]*) ;; + *) echo "unsafe migration preflight schema name" >&2; exit 64 ;; +esac + +cleanup() { + cleanup_failed=0 + if [ "$container_created" -eq 1 ] && docker inspect "$container_name" >/dev/null 2>&1; then + actual_label="$(docker inspect --format '{{ index .Config.Labels "devsam.core2026.task" }}' "$container_name")" + if [ "$actual_label" != "$task_label" ]; then + echo "refusing to remove container with unexpected ownership label: $container_name" >&2 + cleanup_failed=1 + elif ! docker rm -f "$container_name" >/dev/null; then + cleanup_failed=1 + fi + fi + case "$work_dir" in + /tmp/sammo-npc-migration-preflight.*) + if [ -d "$work_dir" ] && ! rm -r -- "$work_dir"; then + cleanup_failed=1 + fi + ;; + *) + echo "refusing to remove unsafe migration preflight work directory: $work_dir" >&2 + cleanup_failed=1 + ;; + esac + if docker inspect "$container_name" >/dev/null 2>&1; then + echo "migration preflight container remains after cleanup: $container_name" >&2 + cleanup_failed=1 + fi + return "$cleanup_failed" +} + +handle_exit() { + exit_status=$? + trap - EXIT HUP INT TERM + if ! cleanup && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi + exit "$exit_status" +} + +trap handle_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +command -v docker >/dev/null 2>&1 || { + echo "docker is required" >&2 + exit 69 +} +command -v pnpm >/dev/null 2>&1 || { + echo "pnpm is required" >&2 + exit 69 +} +[ -d "$prisma_dir/migrations/$target_migration" ] || { + echo "target migration is missing: $target_migration" >&2 + exit 66 +} + +umask 077 +password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" +password_file="$work_dir/postgres_password" +printf '%s\n' "$password" >"$password_file" +chmod 0600 "$password_file" + +docker run -d \ + --name "$container_name" \ + --label "devsam.core2026.task=$task_label" \ + --tmpfs /var/lib/postgresql:rw,nodev,nosuid,size=1g \ + --mount "type=bind,source=$password_file,target=/run/secrets/postgres_password,readonly" \ + -e POSTGRES_DB=sammo \ + -e POSTGRES_USER=sammo \ + -e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \ + -p 127.0.0.1::5432 \ + postgres:18.4-bookworm >/dev/null +container_created=1 + +if [ -n "$(docker inspect --format '{{ range .Mounts }}{{ if eq .Type "volume" }}volume{{ end }}{{ end }}' "$container_name")" ]; then + echo "migration preflight container unexpectedly owns a Docker volume" >&2 + exit 1 +fi + +attempt=0 +until docker exec "$container_name" pg_isready -U sammo -d sammo >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + docker logs --tail 100 "$container_name" >&2 + exit 1 + fi + sleep 1 +done + +published_port="$(docker port "$container_name" 5432/tcp)" +published_port="${published_port##*:}" +case "$published_port" in + ''|*[!0-9]*) echo "could not resolve PostgreSQL host port" >&2; exit 1 ;; +esac + +export POSTGRES_HOST=127.0.0.1 +export POSTGRES_PORT="$published_port" +export POSTGRES_DB=sammo +export POSTGRES_USER=sammo +export POSTGRES_PASSWORD="$password" +export POSTGRES_SCHEMA="$schema_name" +unset DATABASE_URL DATABASE_SCHEMA + +stage_prisma="$work_dir/prisma" +mkdir -p "$stage_prisma/migrations" +cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma" +found_target=0 +for migration_dir in "$prisma_dir"/migrations/[0-9]*; do + migration_name="$(basename "$migration_dir")" + if [ "$migration_name" = "$target_migration" ]; then + found_target=1 + break + fi + cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name" +done +[ "$found_target" -eq 1 ] || { + echo "target migration was not found in migration order" >&2 + exit 1 +} + +cd "$package_dir" +if ! PRISMA_SCHEMA="$stage_prisma/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" \ + >"$work_dir/predecessor-deploy.log" 2>&1; then + sed -n '1,160p' "$work_dir/predecessor-deploy.log" >&2 + exit 1 +fi + +docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo >/dev/null <"$failure_log" 2>&1; then + echo "migration unexpectedly accepted duplicate general owners" >&2 + exit 1 +fi +grep -Fq 'current transaction is aborted' "$failure_log" + +objects_after_failure="$( + docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc " + SELECT + to_regclass('\"$schema_name\".\"select_npc_token\"') IS NULL + AND to_regclass('\"$schema_name\".\"select_npc_token_valid_until_idx\"') IS NULL + AND to_regclass('\"$schema_name\".\"general_user_id_key\"') IS NULL; + " +)" +[ "$objects_after_failure" = "t" ] || { + echo "NPC selection DDL survived the failed transactional migration" >&2 + exit 1 +} + +failed_metadata="$( + docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc " + SELECT + count(*), + bool_and(finished_at IS NULL), + bool_and(rolled_back_at IS NULL), + bool_and(logs IS NULL), + sum(applied_steps_count) + FROM \"$schema_name\".\"_prisma_migrations\" + WHERE migration_name = '$target_migration'; + " +)" +[ "$failed_metadata" = "1|t|t|t|0" ] || { + echo "unexpected target migration metadata after transactional failure: $failed_metadata" >&2 + exit 1 +} + +retry_log="$work_dir/retry-without-resolve.log" +if PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$retry_log" 2>&1; then + echo "Prisma unexpectedly retried a failed migration without resolve" >&2 + exit 1 +fi +if ! grep -Fq 'Error: P3009' "$retry_log"; then + echo "migration retry was not blocked by the unfinished Prisma history row" >&2 + sed -n '1,200p' "$retry_log" >&2 + exit 1 +fi + +docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo >/dev/null <"$work_dir/resolve.log" 2>&1; then + sed -n '1,200p' "$work_dir/resolve.log" >&2 + exit 1 +fi + +PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/recovered-deploy.log" +PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/noop-deploy.log" +grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log" +PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate status --schema "$prisma_dir/game.prisma" >"$work_dir/status.log" +grep -Fq 'Database schema is up to date' "$work_dir/status.log" + +objects_after_recovery="$( + docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc " + SELECT + to_regclass('\"$schema_name\".\"select_npc_token\"') IS NOT NULL + AND to_regclass('\"$schema_name\".\"select_npc_token_valid_until_idx\"') IS NOT NULL + AND to_regclass('\"$schema_name\".\"general_user_id_key\"') IS NOT NULL; + " +)" +[ "$objects_after_recovery" = "t" ] || { + echo "NPC selection table or indexes are missing after recovery" >&2 + exit 1 +} + +migration_rows_after_recovery="$( + docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc " + SELECT + count(*) FILTER (WHERE rolled_back_at IS NOT NULL), + count(*) FILTER (WHERE finished_at IS NOT NULL) + FROM \"$schema_name\".\"_prisma_migrations\" + WHERE migration_name = '$target_migration'; + " +)" +[ "$migration_rows_after_recovery" = "1|1" ] || { + echo "unexpected Prisma migration history after recovery: $migration_rows_after_recovery" >&2 + exit 1 +} + +echo "NPC selection migration duplicate preflight and recovery passed"