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.

Zur Installation springen

Quellinformationen

Repository
blas1n/claude-skills
Letzte Quellaktivitรคt
17. September 2026 um 06:32
Erkannte Sprache von SKILL.md
Englisch
Sterne
2
Forks
0

Installationsoptionen

StandardmรครŸig ist der Prompt ausgewรคhlt, der zuerst die Quelle prรผft. Sie kรถnnen zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prรผfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich fรผr eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen ยท Schreibgeschรผtzte Vorschau
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.
Auf GitHub ansehen