From fe12859ba215b2c86aa6c34368e9204d22546761 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Jul 2026 20:10:45 -0400 Subject: [PATCH] ops: wait for the database on startup (no crash-loop on a slow DB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New thoughtsync.dbwait polls the DB (SELECT 1) up to 60×1s before startup, logging each attempt, so a briefly slow/unready database no longer crash- loops the container. Wired as `python -m thoughtsync.dbwait &&` ahead of `alembic upgrade head` in the image CMD and the dev compose command; exits non-zero after the window so a restart policy can take over. Prod compose gains restart: unless-stopped as the complementary piece. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- Dockerfile | 7 ++--- docker-compose.dev.yml | 3 ++- docker-compose.yml | 1 + src/thoughtsync/dbwait.py | 54 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/thoughtsync/dbwait.py diff --git a/Dockerfile b/Dockerfile index f76fa36..625bc85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ ARG BUILD_VERSION=dev ENV APP_VERSION=$BUILD_VERSION EXPOSE 5000 -# Run migrations, then serve. Family convention (rule 82): schema is built by real -# migrations, never metadata.create_all. -CMD ["sh", "-c", "alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] +# Wait for the database, run migrations, then serve. The DB wait keeps a briefly +# slow/unready database from crash-looping the container. Family convention +# (rule 82): schema is built by real migrations, never metadata.create_all. +CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f2673b9..669a328 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -40,9 +40,10 @@ services: - thoughtsync-dev-data:/var/thoughtsync ports: - "5000:5000" - # Install deps, run migrations, then serve with live reload on the mounted src. + # Install deps, wait for the DB, run migrations, then serve with live reload. command: > sh -c "pip install --quiet -e . && + python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --reload" diff --git a/docker-compose.yml b/docker-compose.yml index 28f14dc..56de89c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: app: build: . + restart: unless-stopped depends_on: db: condition: service_healthy diff --git a/src/thoughtsync/dbwait.py b/src/thoughtsync/dbwait.py new file mode 100644 index 0000000..59ef140 --- /dev/null +++ b/src/thoughtsync/dbwait.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import asyncio +import sys + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from .config import Config + +ATTEMPTS = 60 +DELAY_SECONDS = 1.0 + + +async def _probe(url: str) -> None: + engine = create_async_engine(url) + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + finally: + await engine.dispose() + + +async def wait_for_db(attempts: int = ATTEMPTS, delay: float = DELAY_SECONDS) -> bool: + """Poll the database until it accepts a connection, up to `attempts` tries. + + Startup runs `alembic upgrade head` as the container's first DB touch, so a + database that is a moment slow to accept connections would otherwise crash the + container. This gives it a short window to come up, verifying once per `delay` + seconds, instead of failing on the first missed connection. + """ + url = Config.DATABASE_URL + for attempt in range(1, attempts + 1): + try: + await _probe(url) + print(f"[dbwait] database ready (attempt {attempt}/{attempts})", flush=True) + return True + except Exception as exc: + print( + f"[dbwait] not ready (attempt {attempt}/{attempts}): {exc.__class__.__name__}: {exc}", + flush=True, + ) + if attempt < attempts: + await asyncio.sleep(delay) + print(f"[dbwait] database unreachable after {attempts} attempts; giving up", flush=True) + return False + + +def main() -> int: + return 0 if asyncio.run(wait_for_db()) else 1 + + +if __name__ == "__main__": + sys.exit(main())