Files
2026-07-13 12:34:57 +08:00

632 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
多 worker 服务独立实例化(issue #11)单元测试
覆盖:
1. @service/@client 的 scope 元数据透传(顶层键,装饰期校验)
2. ServiceProvider 的 scope -> Provider 映射(request -> ContextLocalSingleton
3. Application.run() 多 worker 模式下延迟引导(父进程跳过 apply_auto_configuration
4. Application.bootstrap_worker() 环境变量重读 / 调度器门控 / fork 残留重置
5. server._worker_serve 与 bootstrap_worker 的桥接
6. lifespan 中 worker 启动/停止钩子的触发顺序
7. auto_discover 幂等守卫与 @on_worker_start/@on_worker_stop 的 AST 发现
"""
import asyncio
import os
import textwrap
import pytest
from fastapi.testclient import TestClient
from dependency_injector import providers as di_providers
import myboot.core.application as application_module
import myboot.core.server as server_module
from myboot.core.application import Application
from myboot.core.auto_configuration import AutoConfigurationManager
from myboot.core.decorators import (
service,
client,
on_worker_start,
on_worker_stop,
)
from myboot.core.di import DependencyContainer
from myboot.core.di.providers import ServiceProvider
# ---------------------------------------------------------------------------
# 公共 fixture
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clean_worker_env():
"""每个测试前后清理 MYBOOT_* 环境变量
_worker_serve / 测试本身会写入 MYBOOT_WORKER_ID 等变量,
若不清理会污染后续 Application 构造。
"""
saved = {k: v for k, v in os.environ.items() if k.startswith("MYBOOT_")}
for key in saved:
del os.environ[key]
yield
for key in [k for k in os.environ if k.startswith("MYBOOT_")]:
del os.environ[key]
os.environ.update(saved)
@pytest.fixture
def set_config():
"""临时修改全局配置,测试结束后恢复原值"""
from myboot.core.config import get_settings
settings = get_settings()
changed = {}
def _set(key, value):
if key not in changed:
changed[key] = settings.get(key)
settings.set(key, value)
yield _set
for key, old in changed.items():
settings.set(key, old)
# ---------------------------------------------------------------------------
# 1. scope 元数据透传
# ---------------------------------------------------------------------------
class TestScopeDecorators:
def test_service_scope_defaults_to_singleton(self):
@service()
class AlphaService:
pass
assert AlphaService.__myboot_service__["scope"] == "singleton"
def test_service_scope_stored_as_top_level_key(self):
"""scope 必须是顶层键(而非落入 kwargs),注册时才能读到"""
@service(scope="request")
class BetaService:
pass
meta = BetaService.__myboot_service__
assert meta["scope"] == "request"
assert "scope" not in meta["kwargs"]
def test_service_invalid_scope_raises_at_decoration(self):
with pytest.raises(ValueError, match="scope"):
@service(scope="sigleton") # 故意拼错
class TypoService:
pass
def test_client_scope_defaults_to_singleton(self):
@client()
class AlphaClient:
pass
assert AlphaClient.__myboot_client__["scope"] == "singleton"
def test_client_scope_stored_as_top_level_key(self):
@client(scope="factory")
class BetaClient:
pass
meta = BetaClient.__myboot_client__
assert meta["scope"] == "factory"
assert "scope" not in meta["kwargs"]
def test_client_invalid_scope_raises_at_decoration(self):
with pytest.raises(ValueError, match="scope"):
@client(scope="prototype")
class TypoClient:
pass
# ---------------------------------------------------------------------------
# 2. Provider 映射
# ---------------------------------------------------------------------------
class TestProviderScopes:
class _Dummy:
pass
def test_singleton_scope_creates_singleton_provider(self):
sp = ServiceProvider(self._Dummy, "dummy", scope="singleton")
assert isinstance(sp.create_provider(), di_providers.Singleton)
def test_request_scope_creates_context_local_singleton(self):
sp = ServiceProvider(self._Dummy, "dummy", scope="request")
assert isinstance(sp.create_provider(), di_providers.ContextLocalSingleton)
def test_factory_scope_creates_factory_provider(self):
sp = ServiceProvider(self._Dummy, "dummy", scope="factory")
provider = sp.create_provider()
assert isinstance(provider, di_providers.Factory)
assert not isinstance(provider, di_providers.Singleton)
def test_request_scope_instances_per_asyncio_task(self):
"""request 作用域:跨 asyncio 任务不同实例,同一任务内同一实例"""
class ReqService:
pass
container = DependencyContainer()
container.register_service(ReqService, "req_service", scope="request")
container.build_container()
async def resolve_twice():
first = container.get_service("req_service")
second = container.get_service("req_service")
return first, second
async def main():
(a1, a2), (b1, b2) = await asyncio.gather(
resolve_twice(), resolve_twice()
)
return a1, a2, b1, b2
a1, a2, b1, b2 = asyncio.run(main())
assert a1 is a2 # 同一任务(请求)内单例
assert b1 is b2
assert a1 is not b1 # 跨任务(请求)不同实例
# request 作用域不进入单例缓存
assert "req_service" not in container.service_instances
def test_register_service_accepts_decorator_metadata_with_scope(self):
"""回归:装饰器元数据(含顶层 scope 键)作为 config 传入时
不得与 register_service 的显式 scope 参数冲突"""
@service(scope="request")
class MetaService:
pass
meta = MetaService.__myboot_service__
container = DependencyContainer()
container.register_service(
MetaService, meta["name"], scope=meta["scope"], config=meta
)
container.build_container()
provider = container.service_providers[meta["name"]]
assert provider.scope == "request"
def test_factory_scope_not_cached_by_container(self):
class FactService:
pass
container = DependencyContainer()
container.register_service(FactService, "fact_service", scope="factory")
container.build_container()
first = container.get_service("fact_service")
second = container.get_service("fact_service")
assert first is not second
assert "fact_service" not in container.service_instances
# ---------------------------------------------------------------------------
# 3. run() 延迟引导
# ---------------------------------------------------------------------------
class TestRunDefersBootstrap:
def _run(self, monkeypatch, set_config, workers, app_path):
calls = []
monkeypatch.setattr(
application_module, "auto_discover",
lambda pkg: calls.append(("discover", pkg)),
)
monkeypatch.setattr(
application_module, "apply_auto_configuration",
lambda app: calls.append(("apply", app)),
)
app = Application(name="run-defer-test")
monkeypatch.setattr(
app.server_manager, "start_server",
lambda **kwargs: calls.append(("serve", kwargs)),
)
# conf/config.yaml 中 server.workers=1 会覆盖 run() 入参,显式设置
set_config("server.workers", workers)
app.run(workers=workers, app_path=app_path)
return app, calls
def test_multi_worker_with_app_path_skips_apply(self, monkeypatch, set_config):
app, calls = self._run(monkeypatch, set_config, workers=2, app_path="main:app")
events = [name for name, _ in calls]
assert "discover" in events # 父进程仍做 AST 发现(预热缓存)
assert "apply" not in events # 实例化延迟到 worker 内
assert "serve" in events
serve_kwargs = dict(calls)["serve"]
assert serve_kwargs["workers"] == 2
assert serve_kwargs["app_path"] == "main:app"
def test_single_worker_applies_in_parent(self, monkeypatch, set_config):
app, calls = self._run(monkeypatch, set_config, workers=1, app_path=None)
events = [name for name, _ in calls]
assert "discover" in events
assert "apply" in events
def test_multi_worker_without_app_path_falls_back_to_parent_apply(
self, monkeypatch, set_config
):
"""无 app_path 的回退路径(父进程自己 serve)保持原有引导行为"""
app, calls = self._run(monkeypatch, set_config, workers=2, app_path=None)
events = [name for name, _ in calls]
assert "apply" in events
# ---------------------------------------------------------------------------
# 4. bootstrap_worker()
# ---------------------------------------------------------------------------
class TestBootstrapWorker:
def test_reads_worker_env_and_disables_scheduler_for_non_primary(self):
app = Application(name="bw-env-test", auto_configuration=False)
# 构造时无环境变量:默认自认 primary(这正是被修复的 bug 场景)
assert app.is_primary_worker is True
os.environ["MYBOOT_WORKER_ID"] = "2"
os.environ["MYBOOT_WORKER_COUNT"] = "3"
os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0"
old_scheduler = app.scheduler
result = app.bootstrap_worker()
assert app.worker_id == 2
assert app.worker_count == 3
assert app.is_primary_worker is False
# 调度器被重建且按 primary 门控禁用
assert app.scheduler is not old_scheduler
assert app.scheduler.is_enabled() is False
assert result is app.get_fastapi_app()
def test_primary_worker_keeps_scheduler_enabled(self):
app = Application(name="bw-primary-test", auto_configuration=False)
os.environ["MYBOOT_WORKER_ID"] = "1"
os.environ["MYBOOT_WORKER_COUNT"] = "3"
os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "1"
app.bootstrap_worker()
assert app.is_primary_worker is True
assert app.scheduler.is_enabled() is True
def test_scheduler_on_all_workers_overrides_primary_gate(self, set_config):
set_config("scheduler.on_all_workers", True)
app = Application(name="bw-allworkers-test", auto_configuration=False)
os.environ["MYBOOT_WORKER_ID"] = "2"
os.environ["MYBOOT_WORKER_COUNT"] = "2"
os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0"
app.bootstrap_worker()
assert app.is_primary_worker is False
assert app.scheduler.is_enabled() is True
def test_defensive_reset_on_fork_inherited_state(self):
"""fork 子进程继承父进程已引导状态时:清空注册表并重建 FastAPI 应用"""
app = Application(name="bw-reset-test", auto_configuration=False)
class InheritedService:
pass
app.di_container = DependencyContainer()
app.di_container.register_service(InheritedService, "inherited_service")
app.services["inherited_service"] = InheritedService()
app.clients["inherited_client"] = object()
app.components["inherited_component"] = object()
app._client_type_map = {InheritedService: "inherited_client"}
app._component_type_map = {InheritedService: "inherited_component"}
app.worker_start_hooks.append(lambda: None)
app.worker_stop_hooks.append(lambda: None)
old_fastapi_app = app._fastapi_app
result = app.bootstrap_worker()
assert app.services == {}
assert app.clients == {}
assert app.components == {}
assert app._client_type_map == {}
assert app._component_type_map == {}
assert app.worker_start_hooks == []
assert app.worker_stop_hooks == []
assert not app.di_container.service_providers
# FastAPI 应用被重建(丢弃绑定到 fork 前实例的路由)
assert app._fastapi_app is not old_fastapi_app
assert result is app._fastapi_app
def test_clean_state_keeps_pristine_fastapi_app(self):
"""正常延迟引导路径:注册表为空时不触发重置,沿用原 FastAPI 实例"""
app = Application(name="bw-clean-test", auto_configuration=False)
old_fastapi_app = app._fastapi_app
result = app.bootstrap_worker()
assert app._fastapi_app is old_fastapi_app
assert result is old_fastapi_app
def test_runs_auto_configuration_inside_worker(self, monkeypatch):
calls = []
monkeypatch.setattr(
application_module, "auto_discover",
lambda pkg: calls.append(("discover", pkg)),
)
monkeypatch.setattr(
application_module, "apply_auto_configuration",
lambda app: calls.append(("apply", app)),
)
app = Application(name="bw-autoconf-test")
app.bootstrap_worker()
events = [name for name, _ in calls]
assert "discover" in events
assert "apply" in events
def test_auto_configuration_disabled_skips_bootstrap_apply(self, monkeypatch):
calls = []
monkeypatch.setattr(
application_module, "apply_auto_configuration",
lambda app: calls.append("apply"),
)
app = Application(name="bw-noautoconf-test", auto_configuration=False)
app.bootstrap_worker()
assert calls == []
# ---------------------------------------------------------------------------
# 5. _worker_serve 桥接
# ---------------------------------------------------------------------------
class TestWorkerServe:
@pytest.fixture
def serve_recorder(self, monkeypatch):
"""mock hypercorn.asyncio.serve 与 setup_worker_logging"""
import importlib
import hypercorn.asyncio
# 注意:myboot.core 包的 `from .logger import logger` 会用 loguru
# 实例遮蔽包属性 myboot.core.logger,必须经 importlib 拿到真实模块
logger_module = importlib.import_module("myboot.core.logger")
served = {}
async def fake_serve(app, config):
served["app"] = app
served["config"] = config
monkeypatch.setattr(hypercorn.asyncio, "serve", fake_serve)
monkeypatch.setattr(
logger_module, "setup_worker_logging", lambda *a, **k: None
)
return served
def test_myboot_app_served_via_bootstrap_worker(
self, monkeypatch, serve_recorder
):
resolved = object()
monkeypatch.setattr(
server_module, "_resolve_app_from_path", lambda path: resolved
)
bootstrapped_app = object()
bootstrap_calls = []
class FakeMybootApp:
def bootstrap_worker(self):
bootstrap_calls.append(True)
return bootstrapped_app
monkeypatch.setattr(application_module, "_current_app", FakeMybootApp())
server_module._worker_serve("main:app", {}, 2, 3)
# 桥接:serve 的是 bootstrap_worker() 的返回值,而非解析出的原对象
assert bootstrap_calls == [True]
assert serve_recorder["app"] is bootstrapped_app
# 环境变量在引导前已设置
assert os.environ["MYBOOT_WORKER_ID"] == "2"
assert os.environ["MYBOOT_WORKER_COUNT"] == "3"
assert os.environ["MYBOOT_IS_PRIMARY_WORKER"] == "0"
def test_primary_worker_env_flag(self, monkeypatch, serve_recorder):
monkeypatch.setattr(
server_module, "_resolve_app_from_path", lambda path: object()
)
monkeypatch.setattr(application_module, "_current_app", None)
server_module._worker_serve("main:app", {}, 1, 2)
assert os.environ["MYBOOT_IS_PRIMARY_WORKER"] == "1"
def test_plain_asgi_app_path_untouched(self, monkeypatch, serve_recorder):
"""非 myboot 应用(_current_app 为 None):直接 serve 解析出的对象"""
resolved = object()
monkeypatch.setattr(
server_module, "_resolve_app_from_path", lambda path: resolved
)
monkeypatch.setattr(application_module, "_current_app", None)
server_module._worker_serve("asgi_module:app", {}, 2, 2)
assert serve_recorder["app"] is resolved
# ---------------------------------------------------------------------------
# 6. worker 生命周期钩子
# ---------------------------------------------------------------------------
class TestWorkerHookDecorators:
def test_bare_decorator_marks_function(self):
@on_worker_start
def hook():
pass
assert hook.__myboot_worker_hook__ == {"event": "start", "order": 0}
def test_decorator_with_order(self):
@on_worker_stop(order=5)
def hook():
pass
assert hook.__myboot_worker_hook__ == {"event": "stop", "order": 5}
def test_auto_register_worker_hooks_sorted_by_order(self):
class FakeApp:
def __init__(self):
self.worker_start_hooks = []
self.worker_stop_hooks = []
def add_worker_start_hook(self, hook):
self.worker_start_hooks.append(hook)
def add_worker_stop_hook(self, hook):
self.worker_stop_hooks.append(hook)
@on_worker_start(order=2)
def start_late():
pass
@on_worker_start(order=1)
def start_early():
pass
@on_worker_stop
def stop_hook():
pass
manager = AutoConfigurationManager(use_cache=False)
manager.discovered_components["worker_hooks"] = [
{"function": start_late, "module": "m", "type": "function_on_worker_start"},
{"function": start_early, "module": "m", "type": "function_on_worker_start"},
{"function": stop_hook, "module": "m", "type": "function_on_worker_stop"},
]
fake_app = FakeApp()
manager._auto_register_worker_hooks(fake_app)
assert fake_app.worker_start_hooks == [start_early, start_late]
assert fake_app.worker_stop_hooks == [stop_hook]
class TestWorkerHooksInLifespan:
def test_hook_firing_order_in_lifespan(self):
"""startup -> worker_start(按 order) -> scheduler.start ...
scheduler.stop -> worker_stop -> shutdown"""
events = []
class StubScheduler:
def has_jobs(self):
return True
def start(self):
events.append("scheduler_start")
def is_running(self):
return True
def stop(self):
events.append("scheduler_stop")
app = Application(name="hooks-lifespan-test", auto_configuration=False)
app.scheduler = StubScheduler()
def startup_hook():
events.append("startup")
def worker_start_sync():
events.append("worker_start_sync")
async def worker_start_async():
events.append("worker_start_async")
async def worker_stop_async():
events.append("worker_stop_async")
def shutdown_hook():
events.append("shutdown")
app.add_startup_hook(startup_hook)
app.add_worker_start_hook(worker_start_sync)
app.add_worker_start_hook(worker_start_async)
app.add_worker_stop_hook(worker_stop_async)
app.add_shutdown_hook(shutdown_hook)
with TestClient(app.get_fastapi_app()):
assert events == [
"startup",
"worker_start_sync",
"worker_start_async",
"scheduler_start",
]
assert events == [
"startup",
"worker_start_sync",
"worker_start_async",
"scheduler_start",
"scheduler_stop",
"worker_stop_async",
"shutdown",
]
# ---------------------------------------------------------------------------
# 7. auto_discover 幂等 + worker 钩子 AST 发现
# ---------------------------------------------------------------------------
class TestAutoDiscover:
def _make_package(self, tmp_path):
pkg = tmp_path / "demo_pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("", encoding="utf-8")
(pkg / "stuff.py").write_text(
textwrap.dedent(
"""
from myboot.core.decorators import (
service, on_worker_start, on_worker_stop
)
@service()
class DemoService:
pass
@on_worker_start
def start_hook():
pass
@on_worker_stop(order=1)
def stop_hook():
pass
"""
),
encoding="utf-8",
)
return pkg
def test_auto_discover_is_idempotent(self, tmp_path):
self._make_package(tmp_path)
manager = AutoConfigurationManager(app_root=str(tmp_path), use_cache=False)
manager.auto_discover("demo_pkg")
assert len(manager._component_metadata["services"]) == 1
assert len(manager._component_metadata["worker_hooks"]) == 2
# 重复调用(fork 子进程场景):不追加重复条目
manager.auto_discover("demo_pkg")
assert len(manager._component_metadata["services"]) == 1
assert len(manager._component_metadata["worker_hooks"]) == 2
def test_worker_hooks_discovered_via_ast(self, tmp_path):
self._make_package(tmp_path)
manager = AutoConfigurationManager(app_root=str(tmp_path), use_cache=False)
manager.auto_discover("demo_pkg")
hook_types = sorted(
item["type"] for item in manager._component_metadata["worker_hooks"]
)
assert hook_types == [
"function_on_worker_start",
"function_on_worker_stop",
]