""" 多 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", ]