mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user