Skip to main content

alembic-fresh-pg-smoke-test

SQLite-only unit tests cannot catch PostgreSQL-only migration bugs. Add a fresh-PG smoke test that runs `alembic upgrade head` against an empty container so DROP DEFAULT, enum DDL listener collisions, and dependent-object errors fail at PR time instead of on the next dev's first bootstrap.

الانتقال إلى التثبيت

معلومات المصدر

المستودع
blas1n/claude-skills
آخر نشاط في المصدر
١٧ سبتمبر ٢٠٢٦ في ٠٦:٣٢
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٢
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
name
alembic-fresh-pg-smoke-test
description
SQLite-only unit tests cannot catch PostgreSQL-only migration bugs. Add a fresh-PG smoke test that runs `alembic upgrade head` against an empty container so DROP DEFAULT, enum DDL listener collisions, and dependent-object errors fail at PR time instead of on the next dev's first bootstrap.
version
1.0.0
category
trap
# Alembic Fresh-PG Smoke Test ## When to Use Any project where: - Unit tests use **SQLite** (e.g. `aiosqlite`) for speed/isolation, **but** - Production / target deployment runs **PostgreSQL**, **and** - The same Alembic migration tree is supposed to apply to both. If both halves are true, you have a **silent gap**: PG-only DDL semantics (enum types, `ALTER COLUMN TYPE` with bound defaults, `DROP TYPE` dependency chains, partial indexes, etc.) are *never exercised* by your test suite. ## The Hidden Failure Mode Long-lived dev/staging databases get migrated **incrementally**, one revision at a time, as commits land. A migration that contains a structural bug will appear to work because the previous state already had whatever it expected. The bomb only detonates when **someone runs `alembic upgrade head` against a brand-new empty database** — typically a new contributor bootstrapping their laptop, a CI provisioning a fresh container, or a prod cutover. By then the person who wrote the migration is no longer paged in to fix it. ## Concrete Bugs This Catches These are real classes of bugs that pass every SQLite test but break on a fresh PG: 1. **`ALTER COLUMN TYPE` blocked by an enum-typed `DEFAULT`** ``` DependentObjectsStillExistError: cannot drop type tasksource because other objects depend on it DETAIL: default value for column source of table tasks depends on type tasksource ``` Cause: column has `DEFAULT 'foo'` typed as the old enum. `ALTER COLUMN TYPE VARCHAR` succeeds for the column but the default still references the enum, so `DROP TYPE` fails. Fix: `ALTER COLUMN ... DROP DEFAULT` *first*, then re-`SET DEFAULT` after the enum is back. SQLite has no enum-bound defaults. 2. **`CREATE TYPE` colliding with SQLAlchemy's metadata-level enum DDL listener** ``` DuplicateObjectError: type "activitylevel" already exists ``` Cause: `op.execute("CREATE TYPE foo")` followed by `op.create_table(..., sa.Enum(..., name="foo"))` — even with `create_type=False` on the migration's own Enum, the model's metadata-bound listener fires another `CREATE TYPE` inside `create_table`. Fix: drop the explicit `op.execute("CREATE TYPE...")` and let SQLAlchemy create it once, OR wrap in `DO $$ ... EXCEPTION WHEN duplicate_object ...`. SQLite has no enum types. 3. **`DROP TYPE` blocked by surviving FKs / dependent objects** — same family as #1 but with foreign keys, default expressions, views, etc. 4. **Partial / functional indexes** that PG accepts but SQLite silently ignores or rewrites. ## The Smoke Test The pattern: pytest test that spins up a throwaway postgres container via `docker run -d --rm`, waits for `pg_isready`, runs `alembic upgrade head` once, asserts exit 0, tears down. **No new dependencies**, just `subprocess` + the existing `docker` binary. Skips automatically when docker is unavailable so it does not break local laptops without docker. ```python # backend/tests/test_alembic_fresh_migration.py """Smoke test: alembic upgrade head must succeed against a fresh PostgreSQL.""" from __future__ import annotations import os import shutil import socket import subprocess import time import uuid import pytest def _docker_available() -> bool: if shutil.which("docker") is None: return False try: subprocess.run(["docker", "info"], check=True, capture_output=True, timeout=5) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): return False return True def _free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def _wait_for_pg(container: str, timeout_s: float = 30.0) -> None: deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: result = subprocess.run( ["docker", "exec", container, "pg_isready", "-U", "myuser", "-d", "mydb"], capture_output=True, text=True, ) if result.returncode == 0: return time.sleep(0.5) raise TimeoutError("postgres did not become ready in time") pytestmark = pytest.mark.skipif( not _docker_available(), reason="docker is not available; skipping fresh-migration smoke test", ) REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ALEMBIC_INI = os.path.join(REPO_ROOT, "backend", "alembic.ini") def _alembic(database_url: str, *args: str) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["DATABASE_URL"] = database_url return subprocess.run( ["uv", "run", "--project", "backend", "alembic", "-c", ALEMBIC_INI, *args], cwd=REPO_ROOT, env=env, capture_output=True, text=True, timeout=300, ) def test_alembic_upgrade_head_on_fresh_postgres() -> None: container = f"app-migrate-test-{uuid.uuid4().hex[:8]}" port = _free_port() subprocess.run( [ "docker", "run", "-d", "--rm", "--name", container, "-e", "POSTGRES_DB=mydb", "-e", "POSTGRES_USER=myuser", "-e", "POSTGRES_PASSWORD=mypass", "-p", f"127.0.0.1:{port}:5432", "postgres:16-alpine", ], check=True, capture_output=True, ) try: _wait_for_pg(container) url = f"postgresql+asyncpg://myuser:mypass@127.0.0.1:{port}/mydb" result = _alembic(url, "upgrade", "head") assert result.returncode == 0, ( f"upgrade head failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" ) finally: subprocess.run(["docker", "rm", "-f", container], capture_output=True) ``` Runtime: ~2-10 seconds when the postgres image is cached locally. ~30 seconds on a cold CI image pull. ## Why Not Round-Trip (`upgrade head -> downgrade base -> upgrade head`)? Round-trip is stronger but breaks intentionally-lossy migrations (e.g. collapsing a 6-state enum to 4 states cannot be reversed without data loss and is correctly written to `raise NotImplementedError` on downgrade). For those projects, the upgrade-only smoke is the right granularity. Round-trip is appropriate when downgrades are required to be reversible (e.g. deploy-rollback policy). ## Why Not testcontainers / pytest-docker? Both work, but they add a dependency for what is one self-contained test. Plain `subprocess + docker` keeps the test in the same toolchain you already have and removes any version-pin headache. Switch to testcontainers only if you need it for several tests. ## CI Wiring GitHub Actions and most CI services already have `docker` on the runner — the test runs as-is. If your CI uses a postgres *service container* instead, point `DATABASE_URL` at the service and **delete the container-spinup code** (just keep the alembic call). Either shape catches the same bugs. ## Why This Skill Exists I had a Plan view overhaul branch with ~30 alembic revisions. Backend tests were 80%+ green on SQLite. e2e mock tests were 89/89 green. e2e *live* tests ran against a long-lived dev postgres that had been migrated commit-by-commit for weeks. **Two PG-only migration bugs** (DROP DEFAULT trap on the TaskSource enum, CREATE TYPE collision on `activitylevel`) sat undetected the entire time and only surfaced when I tried to run the backend on a fresh laptop postgres. The fix was three lines per bug; the time wasted finding them was hours. A 10-second smoke test would have failed at PR time. The unit-test-vs-deploy-DB engine mismatch is the real root cause; this skill is the cheapest way to close the gap without giving up SQLite's speed for the rest of the suite. --- ## ⚠️ Two gaps this smoke test does NOT close (measured 2026-09-03) The smoke test above proves **DDL** applies to a fresh PG. Two things sit outside it, and both look exactly like coverage. ### 1. Read the gate's `skipped` count, not just `passed` A PG-gated suite **skips silently** when no Postgres is reachable. A full run came back: ``` 6618 passed, 43 skipped ← the whole PG tier, including a schema migration ``` Green. And the migration in that PR had **never run against Postgres at all**. Turn PG on and the same run reads `6662 passed, 1 skipped`. ⇒ For any PR touching schema/migrations, **read the skip count before the pass count**, and open that gate. A disposable container is enough: ```bash docker run -d --name pg-probe -e POSTGRES_USER=app -e POSTGRES_PASSWORD=app \ -e POSTGRES_DB=app -p 15452:5432 pgvector/pgvector:pg16 ``` Pick a port nothing else holds, and confirm the target is **empty** before a suite that starts with `DROP SCHEMA`: ```sql SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'; ``` If the project runs a **two-role** setup (owner for DDL, least-privilege for runtime), the runtime DSN must point at the least-privilege role or the RLS tests fail *correctly* and look like your bug. Let the migration create that role, then switch the DSN. ### 2. A migration's DATA branch is a no-op on an empty database This is the sharp one. `upgrade head` on a fresh DB runs your backfill / exemption / rename UPDATE **against zero rows**. The round trip proves the statement *parses*. It proves nothing about what it does. ```python # Runs on an empty DB → 0 rows → passes no matter what it says op.execute(sa.text(""" UPDATE workspaces SET max_concurrent_runs = NULL WHERE id IN (SELECT m.workspace_id FROM memberships m JOIN users u ON u.id = m.user_id WHERE u.email = :operator_email) """)) ``` That statement existed to keep the operator's own workspace from being locked out on deploy. A green fresh-PG round trip said nothing about it. **Force the branch open**: stop one revision short, seed the rows a real deployment would meet, then upgrade to head and assert the data. ```python _alembic(["upgrade", "<revision BEFORE yours>"], env_extra=env_extra) asyncio.run(_seed_the_rows_a_real_deploy_would_meet()) _alembic(["upgrade", "head"], env_extra=env_extra) caps = asyncio.run(_read_them_back()) assert caps[ordinary_ws] == 3 # backfilled assert caps[operator_ws] is None # exempted ``` Then **delete the UPDATE and confirm that test — and only that test — goes red**. Ours failed with `assert 3 is None`, which is the proof the assertion was load-bearing. ⇒ Rule of thumb: **DDL is covered by the round trip; DML is not.** Every `op.execute(... UPDATE/INSERT/DELETE ...)` needs its own seeded test, because the environment the smoke test builds is precisely the one where that statement does nothing. ## 이름을 틀려도 SQLite 는 전부 초록이다 — 마이그레이션의 테이블 이름 가장 싸고 가장 안 잡히는 실패는 DDL 문법이 아니라 **대상 이름**이다. ```python op.add_column("workers", sa.Column("protocol_version", sa.Integer(), ...)) # ^^^^^^^^^ 실제 테이블은 executor_workers ``` 유닛 스위트는 **한 건도 안 빨개진다.** `Base.metadata.create_all` 은 ORM 의 `__tablename__` 을 보고 만들고, **마이그레이션 파일은 읽지도 않는다.** 그래서 모델 쪽 컬럼은 정상 동작하고, 틀린 건 오직 진짜 PG 에 DDL 을 칠 때 드러난다. 덫이 깊어지는 조건: **그 "당연해 보이는" 이름의 테이블이 과거에 실제로 있었다가 삭제된 경우.** 실측 사례에서 `workers` 는 0행인 채 `drop_dead_worker_tables` (2026-08-21)로 지워졌고 진짜 SoT 는 `executor_workers` 였다. git 로그·옛 코드· 내 기억 전부가 틀린 이름을 지지한다. ⇒ **컬럼을 추가하기 전에 `grep -n '__tablename__' <models.py>` 를 한 번 쳐라.** 모델이 유일한 진실이고, 그걸 확인하는 데 3초 걸린다. 그리고 fresh-PG 스모크가 이 부류를 **전량** 잡는 유일한 게이트다 — 이 실수는 PR 리뷰에서도 잘 안 보인다 (틀린 이름이 그럴듯하기 때문에). ### Related * `the-branch-behind-a-human-gate-was-never-run` — same shape, different gate: code behind a credential/approval has an execution count of zero. * `counting-only-passes-hides-what-never-ran` — a summary that counts only passes erases the third state.
عرض على GitHub