Files
willhaben-tracker/docs/phase-0/task-auto-migration.md
hermes f540cbe7ef feat(phase-0): stability hardening — search path fix, auto-migrations, graceful shutdown, health endpoint
- Remove SET search_path from db.py and migration SQL (Supabase uses public schema)
- Add migrate.py with tracking table for forward-only SQL migrations
- Add entrypoint.sh: waits for DB, runs migrations, then starts app
- Copy 01-schema.sql + zz-seed.sql to worker/src/migrations/
- Add health.py: /health endpoint (200/503) with DB connectivity + scheduler staleness checks
  - /stats endpoint with keyword/ad/notification counts
- Rewrite main.py shutdown sequence: signal handler, 5s grace for scheduler, ordered cleanup
- Update Dockerfile: HEALTHCHECK directive, entrypoint, COPY migrations
- Update docker-compose.yml: stop_grace_period=15s, healthcheck config, env vars
- Add aiohttp>=3.9 to requirements.txt for health server
2026-07-04 11:30:45 -04:00

162 lines
5.9 KiB
Markdown

# Task: Auto-migration on container startup
## Description
The worker currently fails to start when the database schema is missing or outdated (as happened with the `UndefinedTableError` incident). There is no automatic migration mechanism — migrations must be applied manually via `docker cp` + Python scripts.
This task introduces an **automatic, idempotent migration runner** that executes on every container start *before* the worker process begins. It tracks which migrations have been applied in a `migrations` table and applies only the missing ones.
## Architecture
```
Container lifecycle:
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ entrypoint.sh│────►│ python migrate.py│────►│ python │
│ │ │ │ │ main.py │
│ - copy SQLs │ │ 1. Create │ │ (normal app) │
│ - call Python│ │ migrations tbl │ │ │
└──────────────┘ │ 2. Apply pending │ └──────────────┘
│ 3. Seed data │
└──────────────────┘
migrations table:
CREATE TABLE IF NOT EXISTS willhaben_migrations (
name text PRIMARY KEY, -- e.g. "01-schema.sql"
applied_at timestamptz DEFAULT now()
);
Migration directory layout:
worker/src/migrations/
└── 01-schema.sql (the current supabase-migration.sql content)
```
### Key design decisions
- **File-based migrations** — each `.sql` file in `worker/src/migrations/` is a migration. Name = version key.
- **No rollback support** — forward-only, consistent with the simplicity of the project. Rollback means restore from DB backup.
- **Run on every start** — idempotent by checking `willhaben_migrations`. Fast (no-op if all applied).
- **Seed data** included in a dedicated `zz-seed.sql` migration that runs last.
## Implementation Details
### 1. Create `worker/src/migrate.py`
```python
#!/usr/bin/env python3
"""Run pending SQL migrations against the configured PostgreSQL database."""
import os
import glob
import logging
import asyncpg
logger = logging.getLogger(__name__)
MIGRATIONS_DIR = os.path.join(os.path.dirname(__file__), "migrations")
async def run_migrations():
dsn_parts = {
"host": os.getenv("POSTGRES_HOST", "db"),
"port": int(os.getenv("POSTGRES_PORT", "5432")),
"user": os.getenv("POSTGRES_USER", "postgres"),
"password": os.getenv("POSTGRES_PASSWORD"),
"database": os.getenv("POSTGRES_DB", "postgres"),
}
conn = await asyncpg.connect(**dsn_parts)
try:
# Create tracking table (always in public schema for simplicity)
await conn.execute("""
CREATE TABLE IF NOT EXISTS willhaben_migrations (
name text PRIMARY KEY,
applied_at timestamptz DEFAULT now()
);
""")
# Find and sort migration files
sql_files = sorted(glob.glob(os.path.join(MIGRATIONS_DIR, "*.sql")))
for fpath in sql_files:
name = os.path.basename(fpath)
# Check if already applied
exists = await conn.fetchval(
"SELECT 1 FROM willhaben_migrations WHERE name = $1", name
)
if exists:
logger.info("Skipping already-applied migration: %s", name)
continue
logger.info("Applying migration: %s", name)
with open(fpath) as f:
sql = f.read()
# Execute within a transaction (DDL auto-commits in some cases)
await conn.execute(sql)
# Record as applied
await conn.execute(
"INSERT INTO willhaben_migrations (name) VALUES ($1)", name
)
logger.info("Applied migration: %s", name)
finally:
await conn.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
import asyncio
asyncio.run(run_migrations())
```
### 2. Copy SQL migrations into `worker/src/migrations/`
- Copy the content of `supabase/migrations/supabase-migration.sql` to `worker/src/migrations/01-schema.sql`
- **Remove** the `SET search_path TO willhaben_tracker;` line (Phase 0 task: search_path fix)
- Create `worker/src/migrations/zz-seed.sql` with just the admin user seed
### 3. Update `worker/Dockerfile` to copy migration files
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ .
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["python", "main.py"]
```
### 4. Create `worker/src/entrypoint.sh`
```bash
#!/bin/bash
set -e
echo "[entrypoint] Running migrations..."
python /app/migrate.py
echo "[entrypoint] Migrations complete."
# Execute the CMD (main.py)
exec "$@"
```
Make executable: `chmod +x worker/src/entrypoint.sh`
### 5. Update `.env.example` documentation to reflect auto-migration
Remove manual migration instructions; add note that migrations run automatically on container start.
## Acceptance Criteria
- [ ] Starting a fresh container against an empty Supabase database creates all required tables and seeds the admin user — zero manual steps
- [ ] Restarting the same container does NOT re-apply already-applied migrations (logged as "Skipping")
- [ ] Adding a new `02-something.sql` to `worker/src/migrations/` and restarting the container applies only that file
- [ ] The `willhaben_migrations` table accurately tracks all applied migrations with timestamps
- [ ] Migration failures log an error and exit with non-zero code (container restarts via Docker policy)
- [ ] No changes to the existing `main.py` scheduler/bot logic are required