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.

Ir para a instalação

Informações da origem

Repositório
blas1n/claude-skills
Última atividade na origem
17 de setembro de 2026 às 06:32
Idioma detectado do SKILL.md
inglês
Estrelas
2
Forks
0

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
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.
Ver no GitHub