78 lines
2.9 KiB
Python
78 lines
2.9 KiB
Python
"""Regression test: persistence-engine sqlite dir setup must run off the loop.
|
|
|
|
Anchors the production offload in `persistence/engine.py:init_engine`, where the
|
|
SQLite data directory is created with `os.makedirs`. `init_engine` runs on the
|
|
FastAPI lifespan event loop, so a sync `os.makedirs` (a stat + mkdir syscall)
|
|
there blocks startup — the same class of bug fixed for the checkpointer's
|
|
`ensure_sqlite_parent_dir` in #1912 (see `test_sqlite_lifespan.py`).
|
|
|
|
This invokes the production `init_engine(backend="sqlite", ...)` under the strict
|
|
Blockbuster context with a `sqlite_dir` that does not yet exist, so `os.makedirs`
|
|
actually runs. The async engine/session machinery is mocked out so the only host
|
|
filesystem operation under test is the directory creation; if it regresses to run
|
|
directly on the event loop, Blockbuster raises `BlockingError` and this fails.
|
|
|
|
We also stub ``bootstrap_schema`` so the alembic stamp/upgrade path -- which has
|
|
its own ``asyncio.to_thread`` regression anchor in
|
|
``test_persistence_bootstrap.py`` -- does not turn this test into a
|
|
double-coverage one. Keeping concerns separated means a regression in either
|
|
offload (makedirs vs alembic) points at the right place.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Pre-import so `init_engine`'s lazy ``import deerflow.persistence.models`` is a
|
|
# cached no-op rather than a file read under the strict gate.
|
|
import deerflow.persistence.models # noqa: E402,F401
|
|
from deerflow.persistence import engine as engine_mod # noqa: E402
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
def _noop_listens_for(*_args, **_kwargs):
|
|
"""Decorator factory that registers nothing (mock engine has no real events)."""
|
|
|
|
def _decorator(fn):
|
|
return fn
|
|
|
|
return _decorator
|
|
|
|
|
|
async def test_init_engine_sqlite_dir_setup_does_not_block_event_loop(tmp_path: Path) -> None:
|
|
data_dir = tmp_path / "newsubdir" # does not exist yet -> os.makedirs runs
|
|
db_file = data_dir / "app.db"
|
|
|
|
mock_conn = AsyncMock()
|
|
begin_ctx = AsyncMock()
|
|
begin_ctx.__aenter__.return_value = mock_conn
|
|
begin_ctx.__aexit__.return_value = False
|
|
mock_engine = MagicMock()
|
|
mock_engine.begin.return_value = begin_ctx
|
|
mock_engine.dispose = AsyncMock()
|
|
|
|
async def _noop_bootstrap(*_args, **_kwargs):
|
|
return None
|
|
|
|
with (
|
|
patch.object(engine_mod, "create_async_engine", return_value=mock_engine),
|
|
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
|
patch("sqlalchemy.event.listens_for", _noop_listens_for),
|
|
patch(
|
|
"deerflow.persistence.bootstrap.bootstrap_schema",
|
|
new=_noop_bootstrap,
|
|
),
|
|
):
|
|
await engine_mod.init_engine(
|
|
backend="sqlite",
|
|
url=f"sqlite+aiosqlite:///{db_file}",
|
|
sqlite_dir=str(data_dir),
|
|
)
|
|
assert data_dir.exists()
|
|
|
|
await engine_mod.close_engine()
|