Files
2026-07-13 11:59:58 +08:00

76 lines
3.1 KiB
Python

"""Regression: ``bootstrap_schema`` offloads ``alembic.command.stamp`` /
``alembic.command.upgrade`` via ``asyncio.to_thread``.
The alembic commands are synchronous: they open their own engine and execute
DDL. Calling them directly on the FastAPI lifespan event loop would block --
exactly the failure mode of the issue chain that motivated the hybrid
bootstrap (sync IO on the loop = silent stalls / timeouts).
Anchor strategy
---------------
We can't run a real ``init_engine(backend="sqlite", ...)`` under the strict
Blockbuster gate without tripping on ``create_async_engine``'s own
``os.path.abspath`` (which is a pre-existing concern, not the bootstrap's).
The companion ``test_persistence_engine_sqlite.py`` covers the ``init_engine``
makedirs offload by mocking ``create_async_engine`` away entirely. That same
mocking approach would defeat the point here, because the alembic stamp /
upgrade calls in ``bootstrap_schema`` need a *real* on-disk SQLite DB to
exercise.
So this test installs a spy on ``asyncio.to_thread`` and confirms that the
two alembic entry points -- ``_stamp`` and ``_upgrade`` from
``bootstrap_schema`` -- are dispatched through it, not invoked inline. If a
future refactor inlines either call, the spy records zero invocations for
that function and the assertion fails.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
import deerflow.persistence.models # noqa: F401
from deerflow.persistence import bootstrap as bootstrap_mod
pytestmark = pytest.mark.asyncio
@pytest.mark.allow_blocking_io
async def test_bootstrap_offloads_alembic_stamp_and_upgrade(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Stamp + upgrade must go through ``asyncio.to_thread``.
Marked ``allow_blocking_io`` so the strict Blockbuster gate does not flag
incidental blocking IO in test-fixture setup (engine creation paths,
SQLite path resolution). The point of this test is the
``asyncio.to_thread`` wrapping invariant, which the spy below checks
deterministically.
"""
seen: list[str] = []
original_to_thread = asyncio.to_thread
async def spy_to_thread(func, *args, **kwargs):
seen.append(getattr(func, "__name__", repr(func)))
return await original_to_thread(func, *args, **kwargs)
monkeypatch.setattr(bootstrap_mod.asyncio, "to_thread", spy_to_thread)
# Use a real SQLite DB so alembic actually runs stamp + upgrade.
db_path = tmp_path / "spy.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path.as_posix()}")
try:
# Empty branch -> create_all + stamp head. ``_stamp`` must be offloaded.
await bootstrap_mod.bootstrap_schema(engine, backend="sqlite")
assert "_stamp" in seen, f"_stamp not offloaded; saw: {seen}"
# Re-run -> versioned branch -> upgrade head (no-op at head). ``_upgrade`` must be offloaded.
seen.clear()
await bootstrap_mod.bootstrap_schema(engine, backend="sqlite")
assert "_upgrade" in seen, f"_upgrade not offloaded; saw: {seen}"
finally:
await engine.dispose()