""" 引擎的惰性创建。 此前引擎在 import 期创建:`import app.db` 就会按 settings 连库、建出 user.db、SQLite 还会去设一次 WAL——仅仅把这个包 import 进来就有副作用,且违反了「隔离 CONFIG_DIR 必须 早于它」时不会报错,只会静默写进真实的 user.db。 惰性化消掉的是这个副作用(排序约束本身仍在,见 engine 模块注释),代价是引入了新的 正确性问题:首次访问的并发。这个项目有上百个 调度线程,双重检查一旦写错,会创建出多个引擎、各自持一份连接池,额度核算随之失真。 这类 bug 在单线程测试里永远不会暴露,所以这里显式并发压它。 """ import asyncio import subprocess import sys import tempfile import threading import time from unittest.mock import MagicMock import pytest from app.runtime.config import global_vars, settings from app.db import decorators as decorators_module from app.db import engine as engine_module from app.db import session as session_module @pytest.fixture def _reset_engines(): """复原引擎缓存,避免用例之间相互影响。 只做「存档—还原」,不负责释放:用例必须自行给 ``_get_database_engine`` 打桩, 绝不能在这里落下真引擎——还原会把它从槽里丢掉,那条连接便再无人 dispose。 """ saved_sync, saved_async = engine_module._sync_engine, engine_module._async_engine yield engine_module._sync_engine, engine_module._async_engine = saved_sync, saved_async @pytest.fixture def _reset_pooled_engines(): """复原按事件循环缓存的池化引擎。""" saved = dict(session_module._pooled_async_engines) yield session_module._pooled_async_engines.clear() session_module._pooled_async_engines.update(saved) def test_engine_is_not_created_on_import(): """ 仅 import 不得创建引擎——这是「独立可测」的全部意义所在。 必须用子进程验证:当前进程早被其它用例触发过引擎创建了。 """ code = ( "import app.db, app.db.engine as e; " "print('CREATED' if e._sync_engine is not None or e._async_engine is not None " "else 'LAZY')" ) with tempfile.TemporaryDirectory() as tmp: out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env={"PATH": "/usr/bin:/bin", "CONFIG_DIR": tmp, "PYTHONPATH": "."}, timeout=180) assert "LAZY" in out.stdout, f"import 期即创建了引擎:{out.stdout}{out.stderr[-800:]}" def test_importing_legacy_factory_names_does_not_create_engine(): """ `from app.db import SessionFactory` 这类旧写法也不得连带创建引擎。 这三个名字是转发函数而非 sessionmaker 实例,正是为了让「导入」和「创建」分开: 若改回靠模块级 __getattr__ 解析,每个导入方都会在 import 期把引擎建出来。 """ code = ( "from app.db import SessionFactory, AsyncSessionFactory, ScopedSession; " "import app.db.engine as e; " "print('CREATED' if e._sync_engine is not None or e._async_engine is not None " "else 'LAZY')" ) with tempfile.TemporaryDirectory() as tmp: out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env={"PATH": "/usr/bin:/bin", "CONFIG_DIR": tmp, "PYTHONPATH": "."}, timeout=180) assert "LAZY" in out.stdout, f"导入会话工厂即创建了引擎:{out.stdout}{out.stderr[-800:]}" def test_bootstrap_atexit_cleanup_does_not_create_an_engine(): """ 测试引导的退出清理不得为了 dispose 而把引擎创建出来。 `isolate_config_dir` 注册的 atexit 回调原本写作 `app.db.Engine.dispose()`——`Engine` 是惰性解析的属性,取它本身就会**创建**引擎。于是一个只 import 过 `app.db` 的进程会在 解释器关停时凭空连一次库、SQLite 还要再设一遍 journal mode,全部只为随后把它 dispose。 必须用子进程,且断言的是「连库的副作用没有发生」而不是引擎槽位:回调在解释器关停期 才执行,那时已经没有任何代码能跑断言了,能留下的证据只有 stdout。 也必须让子进程自己调 isolate_config_dir()——它只在真的新建了临时目录时才注册回调, 预先把 CONFIG_DIR 塞进环境会让它直接返回、根本不注册 atexit,用例便成了空跑。 """ code = ( "from app.testing.bootstrap import isolate_config_dir; " "isolate_config_dir(); " "import app.db; " "print('IMPORTED')" ) out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env={"PATH": "/usr/bin:/bin", "PYTHONPATH": "."}, timeout=180) assert "IMPORTED" in out.stdout, f"子进程没跑到底:{out.stdout}{out.stderr[-800:]}" assert "journal mode set to" not in out.stdout, ( f"退出清理凭空建了个引擎、连了一次库:{out.stdout}{out.stderr[-800:]}" ) def test_db_query_decorator_resolves_session_at_call_time(monkeypatch): """ 装饰器必须能在调用期取到会话。 守的是一个真实踩过的坑:曾试图用模块级 __getattr__(PEP 562)把 ScopedSession 延迟到运行期解析,但 __getattr__ 只对「对模块对象取属性」生效,装饰器函数体里的 裸名字 ScopedSession 是**全局名字查找**,只查模块 __dict__ 和 builtins,永远走不 到 __getattr__ —— 结果是运行期 NameError。它只在真正调用到某个 Oper 时才炸, import 与单测都照常绿灯,因此必须显式钉住。 """ fake_session = MagicMock() # 替身打在 session 模块的工厂上,而不是 decorators.ScopedSession:后者会把 # 名字直接塞进 decorators 的 __dict__,反而掩盖「这个名字本来就该在」的缺陷。 monkeypatch.setattr(session_module, "get_scoped_session", lambda: (lambda: fake_session)) @decorators_module.db_query def _fetch(db=None): """装饰器未拿到会话时应自行创建一个并塞回 db 位置。""" return db # 按各 Oper 的常态调用:db 显式传 None,由装饰器补上会话 assert _fetch(db=None) is fake_session fake_session.close.assert_called_once() def test_concurrent_first_access_creates_exactly_one_engine(_reset_engines, monkeypatch): """ 多线程同时首次取引擎,只能创建出一个实例。 创建多个意味着每个都带一份连接池:实际连接数是额度核算的数倍,而校验对此 一无所知——正是这次修复想避免的那类问题。 """ engine_module._sync_engine = None created = [] barrier = threading.Barrier(16) def slow_factory(**_kwargs): """放大创建耗时,把竞态窗口撑开到必定命中。""" time.sleep(0.02) marker = object() created.append(marker) return marker monkeypatch.setattr(engine_module, "_get_database_engine", slow_factory) got = [] def worker(): """所有线程在同一时刻发起首次访问。""" barrier.wait() got.append(engine_module.get_engine()) threads = [threading.Thread(target=worker) for _ in range(16)] for t in threads: t.start() for t in threads: t.join() assert len(created) == 1, f"并发首次访问创建了 {len(created)} 个引擎" assert len({id(g) for g in got}) == 1, "不同线程拿到了不同的引擎实例" def test_repeated_access_reuses_the_same_engine(_reset_engines, monkeypatch): """ 后续访问必须复用,而不是每次重建。 """ engine_module._sync_engine = None calls = [] monkeypatch.setattr(engine_module, "_get_database_engine", lambda **kw: calls.append(kw) or object()) first = engine_module.get_engine() second = engine_module.get_engine() assert first is second assert len(calls) == 1 def test_async_engine_has_its_own_lazy_slot(_reset_engines, monkeypatch): """ 同步与异步引擎各自独立惰性化,取其中一个不应连带创建另一个。 """ engine_module._sync_engine = engine_module._async_engine = None monkeypatch.setattr(engine_module, "_get_database_engine", lambda **kw: object()) engine_module.get_engine() assert engine_module._sync_engine is not None assert engine_module._async_engine is None, "取同步引擎连带创建了异步引擎" def test_pooled_path_does_not_create_the_global_async_engine( _reset_engines, _reset_pooled_engines, monkeypatch): """ 常驻循环走池化引擎时,全局异步引擎必须原封不动地留在「未创建」状态。 钉的是一处已经踩过的实现:async_session_scope 曾用 `engine is not get_global_async_engine()` 反推是否池化——这个比较**本身**就把被比较的 引擎创建了出来。它倒不至于多占连接——全局异步引擎用的是 NullPool,持有 0 条连接; 真正的代价是第一个异步请求会在事件循环内部去抢引擎创建锁,把本该无锁的热路径变成 有锁的,而这个引擎在常驻循环下从头到尾无人使用。这类问题不会让任何断言变红,只能显式压。 """ monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False) engine_module._async_engine = None def _boom(): """任何对全局异步引擎的获取都是失败信号。""" raise AssertionError("池化路径获取了全局异步引擎") # 打在 session 模块的名字上:_resolve_async_engine 用的是它自己 __dict__ 里的这个名字 monkeypatch.setattr(session_module, "get_global_async_engine", _boom) async def run(): """在「当前循环即常驻循环」的前提下真的走一遍会话作用域。""" global_vars.CURRENT_EVENT_LOOP = asyncio.get_running_loop() session_module._pooled_async_engines.clear() try: async with session_module.async_session_scope(): pass finally: # 用真引擎(不给工厂打桩)才能验证会话确实能建起来;建了就得自己释放 for pooled in session_module._pooled_async_engines.values(): await pooled.dispose() session_module._pooled_async_engines.clear() saved_loop = global_vars.CURRENT_EVENT_LOOP try: asyncio.run(run()) finally: global_vars.CURRENT_EVENT_LOOP = saved_loop assert engine_module._async_engine is None, "池化路径把全局异步引擎创建了出来" def test_engine_module_exposes_no_legacy_names(_reset_engines, monkeypatch): """ app.db.engine 不再解析 Engine / AsyncEngine 两个旧名字。 这两个名字的对外契约是 `app.db.Engine`(由 app/db/__init__.py 的 __getattr__ 提供)。 app.db.engine 这个模块是拆分时才出现的,仓库外不可能有代码依赖它,实现模块上那份 同名转发因此是纯冗余——两处独立实现同一个契约,改一处漏一处就会各取到一个引擎。 仓库内一律用 get_engine() / get_global_async_engine()。 工厂打桩而不是让它建真引擎:_reset_engines 还原槽位时会把真引擎丢掉, 那条连接就再没人 dispose 了。 """ engine_module._sync_engine = engine_module._async_engine = None monkeypatch.setattr(engine_module, "_get_database_engine", lambda **kw: object()) with pytest.raises(AttributeError): _ = engine_module.Engine with pytest.raises(AttributeError): _ = engine_module.AsyncEngine # 取过之后引擎槽位仍是空的:属性访问没有绕开 getter 把引擎建出来 assert engine_module._sync_engine is None assert engine_module._async_engine is None def test_package_entry_resolves_legacy_names(_reset_engines, monkeypatch): """ app.db.Engine / app.db.AsyncEngine 仍解析到与 getter 同一个引擎。 这才是真正的对外契约:仓库外的插件按这两个名字取引擎,建表、Alembic 迁移、连接 诊断这类用途确实需要引擎对象本身,装饰器覆盖不到。上一条用例删掉了实现模块上的 冗余转发,这条钉住包入口那份**不能**跟着删。 与惰性不冲突:属性访问发生在运行期,而不是 import 期。 """ import app.db as db_package engine_module._sync_engine = engine_module._async_engine = None monkeypatch.setattr(engine_module, "_get_database_engine", lambda **kw: object()) assert db_package.Engine is engine_module.get_engine() assert db_package.AsyncEngine is engine_module.get_global_async_engine() def test_package_entry_unknown_attribute_still_raises(): """ 包入口的模块级 __getattr__ 不能吞掉拼写错误。 """ import app.db as db_package with pytest.raises(AttributeError): _ = db_package.NoSuchThing