Fix migration failures: switch to full TimescaleDB + use psql runner

- Change image from timescaledb-ha:pg16-ts2.15-oss to pg16-ts2.15
  (OSS edition lacks compression, retention, continuous aggregates)
- Add postgresql-client to Dockerfile for psql binary
- Rewrite run_migrations.py to use psql instead of psycopg2
  (psql runs each statement independently; psycopg2 wraps the
  entire file in one transaction so one error rolls back everything)
- Add schema verification: exits 1 if critical tables missing,
  preventing services from starting with broken schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
David Kiania 2026-04-08 17:17:58 +03:00
parent 3bbf3b777d
commit 326764e1a0
3 changed files with 59 additions and 36 deletions

View file

@ -7,6 +7,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Install system dependencies (Required for Postgres and Healthchecks) # Install system dependencies (Required for Postgres and Healthchecks)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libpq5 \ libpq5 \
postgresql-client \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View file

@ -1,6 +1,6 @@
services: services:
timescale_db: timescale_db:
image: timescale/timescaledb-ha:pg16-ts2.15-oss image: timescale/timescaledb-ha:pg16-ts2.15
restart: always restart: always
environment: environment:
- POSTGRES_DB=${POSTGRES_DB} - POSTGRES_DB=${POSTGRES_DB}

View file

@ -1,11 +1,13 @@
""" """
run_migrations.py Idempotent SQL migration runner for Docker init service. run_migrations.py Idempotent SQL migration runner for Docker init.
Executes each .sql migration file in order using psycopg2. Uses psql (not psycopg2) so each statement runs independently
Tolerates re-run errors (e.g. "policy already exists") so deploys are safe. one error doesn't roll back the entire file.
""" """
import os import os
import subprocess
import sys import sys
import psycopg2 import psycopg2
DATABASE_URL = os.environ["DATABASE_URL"] DATABASE_URL = os.environ["DATABASE_URL"]
@ -15,51 +17,71 @@ MIGRATIONS = [
"03_webhook_schema_migration.sql", "03_webhook_schema_migration.sql",
] ]
CRITICAL_TABLES = [
"tracksolid.devices",
"tracksolid.api_token_cache",
"tracksolid.ingestion_log",
"tracksolid.live_positions",
"tracksolid.position_history",
"tracksolid.trips",
"tracksolid.alarms",
"tracksolid.obd_readings",
]
def run_file(conn, path, filename):
"""Execute a SQL file. Returns True on success, False on error.""" def run_file(path, filename):
with open(path) as f: """Execute a SQL file via psql. Returns True on success."""
sql = f.read() print(f"Running {filename}...")
try: result = subprocess.run(
with conn.cursor() as cur: ["psql", DATABASE_URL, "-f", path],
cur.execute(sql) capture_output=True, text=True,
)
# psql prints errors to stderr but continues by default
errors = [l for l in result.stderr.splitlines() if "ERROR:" in l]
if errors:
for e in errors:
print(f" WARN: {e.strip()}")
return False
print(f" OK: {filename}") print(f" OK: {filename}")
return True return True
except psycopg2.Error as e:
msg = (e.pgerror or str(e)).strip().split("\n")[0]
print(f" WARN: {filename}: {msg}") def verify_schema():
# Connection is now in error state — must reset """Verify critical tables exist. Exit 1 if not — prevents services from starting."""
print("Verifying schema...")
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
missing = []
for table in CRITICAL_TABLES:
schema, name = table.split(".")
cur.execute(
"SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name=%s",
(schema, name),
)
if not cur.fetchone():
missing.append(table)
cur.close()
conn.close() conn.close()
return False
if missing:
print(f"FATAL: Missing critical tables: {', '.join(missing)}")
print("Schema bootstrap failed. Services cannot start.")
sys.exit(1)
print(" All critical tables verified.")
def main(): def main():
print("=== Database Migration Runner ===") print("=== Database Migration Runner ===")
conn = psycopg2.connect(DATABASE_URL)
conn.autocommit = True
warnings = 0
for sql_file in MIGRATIONS: for sql_file in MIGRATIONS:
path = os.path.join("/app", sql_file) path = os.path.join("/app", sql_file)
if not os.path.exists(path): if not os.path.exists(path):
print(f" SKIP: {sql_file} (not found)") print(f" SKIP: {sql_file} (not found)")
continue continue
run_file(path, sql_file)
print(f"Running {sql_file}...") verify_schema()
ok = run_file(conn, path, sql_file) print("Migrations complete.")
if not ok:
warnings += 1
# Reconnect for the next file
conn = psycopg2.connect(DATABASE_URL)
conn.autocommit = True
conn.close()
if warnings:
print(f"Completed with {warnings} warning(s) (expected on re-deploy).")
else:
print("All migrations applied cleanly.")
sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":