fix(db): latch init_db per database and time-bound the alembic runner (#342)

Bootstrap and the API lifespan both ran init_db in one process seconds
apart; the second call re-entered the alembic-in-thread machinery
(nested asyncio.run + NullPool engine + greenlet bridge in a reused
worker thread) for zero benefit and hung two consecutive NAS boots
there, blocking the API bind forever with zero SQL activity. init_db
now latches per database URL (drop_db resets it; a different DB always
runs fully), and the alembic worker is bounded at 300s -- a wedged
thread fails startup loudly with a pinpointed error so the container
restarts into a clean retry instead of hanging silently.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-08 23:39:39 +02:00
committed by GitHub
co-authored by Renn F
parent 5e7c498d00
commit 5886336259
2 changed files with 135 additions and 1 deletions
+92
View File
@@ -8,6 +8,7 @@ and drop/close.
from __future__ import annotations
import time
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, MagicMock, patch
@@ -17,6 +18,7 @@ from roboco.db.base import (
_db_has_alembic_version,
_db_has_tables,
_DbHolder,
_InitState,
close_db,
drop_db,
get_db,
@@ -39,9 +41,11 @@ def _reset_holder() -> Generator[None]:
"""Snapshot/restore the singleton so tests don't poison the live engine."""
saved_engine = _DbHolder.engine
saved_factory = _DbHolder.session_factory
_InitState.completed_url = None
yield
_DbHolder.engine = saved_engine
_DbHolder.session_factory = saved_factory
_InitState.completed_url = None
# ---------------------------------------------------------------------------
@@ -454,6 +458,94 @@ async def test_init_db_fresh_db_runs_migrations() -> None:
fake_engine.dispose.assert_awaited_once()
def _fake_engine_for_init() -> tuple[MagicMock, MagicMock]:
fake_conn = MagicMock()
fake_conn.execute = AsyncMock()
fake_conn.run_sync = AsyncMock()
class _ConnCm:
async def __aenter__(self) -> object:
return fake_conn
async def __aexit__(self, *_args: object) -> None:
return None
fake_engine = MagicMock()
fake_engine.begin = MagicMock(return_value=_ConnCm())
fake_engine.connect = MagicMock(return_value=_ConnCm())
fake_engine.dispose = AsyncMock()
return fake_engine, fake_conn
@pytest.mark.asyncio
async def test_init_db_second_call_same_db_is_noop() -> None:
"""Bootstrap and the API lifespan both call init_db in one process; the
second call must not re-enter the alembic machinery (2026-07-08 NAS hang)."""
fake_engine, _ = _fake_engine_for_init()
with (
patch("roboco.db.base.get_engine", return_value=fake_engine),
patch("roboco.db.base._db_has_tables", new=AsyncMock(return_value=True)),
patch("roboco.db.base.run_migrations", new=AsyncMock()) as rm,
):
await init_db()
await init_db()
rm.assert_awaited_once()
fake_engine.dispose.assert_awaited_once()
@pytest.mark.asyncio
async def test_init_db_reruns_for_a_different_database_url() -> None:
"""The latch is URL-keyed: a process initializing a different DB runs fully."""
_InitState.completed_url = "postgresql+asyncpg://other-host/other-db"
fake_engine, _ = _fake_engine_for_init()
with (
patch("roboco.db.base.get_engine", return_value=fake_engine),
patch("roboco.db.base._db_has_tables", new=AsyncMock(return_value=True)),
patch("roboco.db.base.run_migrations", new=AsyncMock()) as rm,
):
await init_db()
rm.assert_awaited_once()
@pytest.mark.asyncio
async def test_drop_db_resets_the_init_latch() -> None:
"""drop_db clears the latch so a rebuild in the same process runs fully."""
fake_engine, _ = _fake_engine_for_init()
with (
patch("roboco.db.base.get_engine", return_value=fake_engine),
patch("roboco.db.base._db_has_tables", new=AsyncMock(return_value=True)),
patch("roboco.db.base.run_migrations", new=AsyncMock()) as rm,
):
await init_db()
await drop_db()
await init_db()
expected_full_runs = 2
assert rm.await_count == expected_full_runs
@pytest.mark.asyncio
async def test_run_migrations_times_out_loudly_on_wedged_worker() -> None:
"""A wedged alembic worker thread fails startup with a clear error instead
of hanging the API bind forever (the 2026-07-08 boot-hang shape)."""
fake_engine, _ = _fake_engine_for_init()
fake_command = MagicMock()
fake_command.upgrade = MagicMock(side_effect=lambda *_a, **_k: time.sleep(0.5))
with (
patch("roboco.db.base.get_engine", return_value=fake_engine),
patch(
"roboco.db.base._db_has_alembic_version",
new=AsyncMock(return_value=True),
),
patch("roboco.db.base.command", fake_command),
patch("roboco.db.base._ALEMBIC_TIMEOUT_SECONDS", 0.05),
pytest.raises(RuntimeError, match="alembic migration runner exceeded"),
):
await run_migrations()
# ---------------------------------------------------------------------------
# drop_db / close_db
# ---------------------------------------------------------------------------