test_environment.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. """公共生命周期:启动失败、取消、重复进入以及真实 SQLite 资源释放。"""
  2. import asyncio
  3. from concurrent.futures import ThreadPoolExecutor
  4. import threading
  5. from types import SimpleNamespace
  6. import pytest
  7. from langchain_core.messages import AIMessageChunk
  8. import config
  9. from agents import agent_memory, agent_registry
  10. from db import db_conn, chat_store, billing_store
  11. from runtime import environment
  12. from runtime.environment import RuntimeEnvironment
  13. from runtime.execution import execute
  14. from scheduler import scheduler
  15. @pytest.fixture
  16. def lifecycle(monkeypatch):
  17. calls = []
  18. def mark(name):
  19. def call(*args, **kwargs):
  20. calls.append(name)
  21. return call
  22. def amark(name):
  23. async def call(*args, **kwargs):
  24. calls.append(name)
  25. return call
  26. for name in ('setup_logging', 'approval_names'):
  27. monkeypatch.setattr(environment, name, mark(name))
  28. from extensions.registry import ExtensionRegistry
  29. monkeypatch.setattr(environment, 'load_extensions', lambda: ExtensionRegistry())
  30. monkeypatch.setattr(config, 'log_config_sources', mark('config'))
  31. monkeypatch.setattr(db_conn, 'init_schema', mark('schema'))
  32. monkeypatch.setattr(db_conn, 'health', lambda: {'status': 'ok'})
  33. monkeypatch.setattr(db_conn, 'close', mark('db_close'))
  34. monkeypatch.setattr(environment.pricing, 'seed_default_pricing', mark('pricing'))
  35. monkeypatch.setattr(environment.run_store, 'recover_interrupted', mark('recover'))
  36. monkeypatch.setattr(agent_registry, 'invalidate', mark('invalidate'))
  37. monkeypatch.setattr(agent_memory, 'init_checkpointer', amark('checkpoint'))
  38. monkeypatch.setattr(agent_memory, 'close_checkpointer', amark('checkpoint_close'))
  39. monkeypatch.setattr(scheduler, 'start', amark('scheduler_start'))
  40. monkeypatch.setattr(scheduler, 'shutdown', amark('scheduler_stop'))
  41. return calls
  42. @pytest.mark.asyncio
  43. async def test_default_environment_and_sequential_reuse(lifecycle):
  44. env = RuntimeEnvironment(prewarm=False, billing_retry=False)
  45. for _ in range(2):
  46. lifecycle.clear()
  47. async with env as current:
  48. assert current is env
  49. assert lifecycle[-4:] == ['pricing', 'recover', 'invalidate', 'checkpoint']
  50. assert lifecycle[-3:] == ['invalidate', 'checkpoint_close', 'db_close']
  51. assert 'scheduler_start' not in lifecycle
  52. @pytest.mark.asyncio
  53. async def test_nested_environment_does_not_close_outer(lifecycle):
  54. env = RuntimeEnvironment(prewarm=False, billing_retry=False)
  55. async with env:
  56. for other in (env, RuntimeEnvironment()):
  57. with pytest.raises(RuntimeError, match='已有活动'):
  58. async with other:
  59. pytest.fail('不应启动嵌套环境')
  60. assert 'checkpoint_close' not in lifecycle
  61. assert lifecycle.count('db_close') == 1
  62. @pytest.mark.asyncio
  63. @pytest.mark.parametrize('stage', ['pricing', 'checkpoint', 'scheduler'])
  64. async def test_partial_startup_failure_cleans_and_allows_restart(stage, lifecycle, monkeypatch):
  65. def fail():
  66. raise ValueError(stage)
  67. async def afail():
  68. raise ValueError(stage)
  69. with monkeypatch.context() as patch:
  70. if stage == 'pricing':
  71. patch.setattr(environment.pricing, 'seed_default_pricing', fail)
  72. elif stage == 'checkpoint':
  73. patch.setattr(agent_memory, 'init_checkpointer', afail)
  74. else:
  75. patch.setattr(scheduler, 'start', afail)
  76. with pytest.raises(ValueError, match=stage):
  77. async with RuntimeEnvironment(scheduler=True, prewarm=False, billing_retry=False):
  78. pytest.fail('启动失败后不应进入执行体')
  79. assert lifecycle[-1] == 'db_close'
  80. if stage != 'pricing':
  81. assert 'checkpoint_close' in lifecycle
  82. if stage == 'scheduler':
  83. assert lifecycle.index('scheduler_stop') < lifecycle.index('checkpoint_close')
  84. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  85. pass
  86. @pytest.mark.asyncio
  87. async def test_body_exception_and_cleanup_error_still_release_resources(lifecycle, monkeypatch):
  88. async def fail_close():
  89. lifecycle.append('checkpoint_close')
  90. raise RuntimeError('close failed')
  91. with monkeypatch.context() as patch:
  92. patch.setattr(agent_memory, 'close_checkpointer', fail_close)
  93. with pytest.raises(RuntimeError, match='close failed'):
  94. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  95. raise ValueError('body failed')
  96. assert lifecycle[-1] == 'db_close'
  97. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  98. pass
  99. @pytest.mark.asyncio
  100. async def test_cancel_during_startup_waits_for_initialization(lifecycle, monkeypatch):
  101. started, finish = asyncio.Event(), asyncio.Event()
  102. async def init():
  103. started.set()
  104. await finish.wait()
  105. lifecycle.append('checkpoint')
  106. monkeypatch.setattr(agent_memory, 'init_checkpointer', init)
  107. async def run():
  108. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  109. pytest.fail('取消后不应进入执行体')
  110. task = asyncio.create_task(run())
  111. await started.wait()
  112. task.cancel()
  113. await asyncio.sleep(0)
  114. task.cancel() # 清理期间再次取消,也要等待底层初始化收尾。
  115. assert 'db_close' not in lifecycle
  116. finish.set()
  117. with pytest.raises(asyncio.CancelledError):
  118. await task
  119. assert lifecycle[-3:] == ['checkpoint', 'checkpoint_close', 'db_close']
  120. @pytest.mark.asyncio
  121. async def test_shutdown_waits_for_workers_before_closing_connections(lifecycle, monkeypatch):
  122. from billing import meter
  123. prewarm_started, billing_started = threading.Event(), threading.Event()
  124. release = threading.Event()
  125. def compile_agent():
  126. prewarm_started.set()
  127. assert release.wait(5)
  128. lifecycle.append('compiled')
  129. def retry():
  130. billing_started.set()
  131. assert release.wait(5)
  132. lifecycle.append('settled')
  133. monkeypatch.setattr(agent_registry, 'get_agent', compile_agent)
  134. monkeypatch.setattr(meter, 'retry_pending', retry)
  135. running = asyncio.Event()
  136. async def run():
  137. async with RuntimeEnvironment(scheduler=True):
  138. running.set()
  139. await asyncio.Event().wait()
  140. task = asyncio.create_task(run())
  141. try:
  142. await running.wait()
  143. assert await asyncio.to_thread(prewarm_started.wait, 2)
  144. assert await asyncio.to_thread(billing_started.wait, 2)
  145. task.cancel()
  146. await asyncio.sleep(0)
  147. await asyncio.sleep(0)
  148. assert 'checkpoint_close' not in lifecycle
  149. finally:
  150. release.set()
  151. with pytest.raises(asyncio.CancelledError):
  152. await task
  153. assert lifecycle.index('scheduler_stop') < lifecycle.index('checkpoint_close')
  154. assert lifecycle.index('settled') < lifecycle.index('db_close')
  155. assert lifecycle.index('compiled') < lifecycle.index('checkpoint_close')
  156. assert not [t for t in asyncio.all_tasks() if t.get_name().startswith('runtime-')]
  157. class FakeAgent:
  158. def __init__(self):
  159. self.checkpointer = agent_memory.get_checkpointer()
  160. async def aget_state(self, options):
  161. return SimpleNamespace(tasks=[])
  162. async def astream(self, *args, **kwargs):
  163. yield {'type': 'messages', 'data': (AIMessageChunk(content='报告完成', usage_metadata={
  164. 'input_tokens': 8, 'output_tokens': 2, 'total_tokens': 10}), {})}
  165. @pytest.mark.asyncio
  166. async def test_sqlite_script_executes_bills_and_reopens(monkeypatch, tmp_path):
  167. # 独立业务库与 checkpoint,不使用线上配置,也不调用模型网络。
  168. adapter = db_conn._SqliteAdapter(str(tmp_path / 'business.sqlite'))
  169. monkeypatch.setattr(db_conn, '_adapter', adapter)
  170. monkeypatch.setattr(db_conn, '_schema_ready', False)
  171. monkeypatch.setattr(agent_memory, '_saver', None)
  172. monkeypatch.setattr(agent_memory, '_kind', 'sqlite')
  173. monkeypatch.setattr(config, 'CHECKPOINT_DB_PATH', str(tmp_path / 'checkpoint.sqlite'))
  174. monkeypatch.setattr(agent_registry._slot, '_compile', FakeAgent)
  175. schema = config.ROOT / 'db' / 'schema_sqlite.sql'
  176. monkeypatch.setattr(adapter, 'schema_file', lambda: schema)
  177. monkeypatch.setattr(config, 'ROOT', tmp_path)
  178. previous = None
  179. env = RuntimeEnvironment(prewarm=False, billing_retry=False)
  180. for _ in range(2):
  181. async with env:
  182. session = chat_store.create_session('runtime-test')
  183. agent = agent_registry.get_agent()
  184. assert agent is not previous
  185. assert agent.checkpointer is agent_memory.get_checkpointer()
  186. result = await execute(user_name='runtime-test', session_id=session['id'], message='生成报告')
  187. assert result.status == 'success'
  188. assert result.output == '报告完成'
  189. assert result.usage['total_tokens'] == 10
  190. assert billing_store.list_pricing()
  191. assert db_conn.scalar('SELECT total_tokens FROM billing_usage WHERE session_id = ?',
  192. (session['id'],)) == 10
  193. conn = adapter._conn
  194. saver_conn = agent.checkpointer.conn
  195. previous = agent
  196. assert adapter._conn is None
  197. with pytest.raises(Exception, match='closed'):
  198. conn.execute('SELECT 1')
  199. with pytest.raises(ValueError, match='no active connection'):
  200. await saver_conn.execute('SELECT 1')
  201. assert not agent_memory.is_initialized()
  202. assert agent_registry.peek_agent() is None
  203. def test_mysql_closes_connections_from_all_threads_and_reopens():
  204. adapter = db_conn._MySqlAdapter()
  205. class Connection:
  206. def __init__(self):
  207. self.closed = False
  208. def close(self):
  209. self.closed = True
  210. def ping(self, **kwargs):
  211. assert not self.closed
  212. adapter._connect = lambda database: Connection()
  213. barrier = threading.Barrier(3)
  214. def connect():
  215. conn = adapter._connection()
  216. barrier.wait(timeout=3)
  217. return conn
  218. with ThreadPoolExecutor(max_workers=2) as pool:
  219. tasks = [pool.submit(connect) for _ in range(2)]
  220. main_conn = adapter._connection()
  221. barrier.wait(timeout=3)
  222. connections = [main_conn, *(task.result() for task in tasks)]
  223. assert len({id(conn) for conn in connections}) == 3
  224. adapter.close()
  225. assert all(conn.closed for conn in connections)
  226. assert pool.submit(adapter._connection).result() not in connections
  227. assert adapter._connection() not in connections
  228. adapter.close()
  229. @pytest.mark.asyncio
  230. async def test_checkpoint_setup_failure_closes_partial_connection(monkeypatch, tmp_path):
  231. import aiosqlite
  232. from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
  233. monkeypatch.setattr(agent_memory, '_saver', None)
  234. monkeypatch.setattr(agent_memory, '_kind', 'sqlite')
  235. monkeypatch.setattr(config, 'CHECKPOINT_DB_PATH', str(tmp_path / 'bad.sqlite'))
  236. connected = []
  237. original = aiosqlite.connect
  238. async def connect(*args, **kwargs):
  239. conn = await original(*args, **kwargs)
  240. connected.append(conn)
  241. return conn
  242. async def fail(self):
  243. raise ValueError('setup failed')
  244. monkeypatch.setattr(aiosqlite, 'connect', connect)
  245. monkeypatch.setattr(AsyncSqliteSaver, 'setup', fail)
  246. with pytest.raises(agent_memory.CheckpointerConfigError, match='setup failed'):
  247. await agent_memory.init_checkpointer()
  248. assert not agent_memory.is_initialized()
  249. with pytest.raises(ValueError, match='no active connection'):
  250. await connected[0].execute('SELECT 1')
  251. @pytest.mark.asyncio
  252. async def test_scheduler_start_cancellation_waits_for_database_read(lifecycle, monkeypatch):
  253. started, finish = asyncio.Event(), asyncio.Event()
  254. async def start():
  255. started.set()
  256. await finish.wait()
  257. lifecycle.append('scheduler_loaded')
  258. monkeypatch.setattr(scheduler, 'start', start)
  259. async def run():
  260. async with RuntimeEnvironment(scheduler=True, prewarm=False, billing_retry=False):
  261. pytest.fail('取消后不应进入执行体')
  262. task = asyncio.create_task(run())
  263. await started.wait()
  264. task.cancel()
  265. await asyncio.sleep(0)
  266. assert 'db_close' not in lifecycle
  267. finish.set()
  268. with pytest.raises(asyncio.CancelledError):
  269. await task
  270. assert lifecycle.index('scheduler_loaded') < lifecycle.index('scheduler_stop')
  271. assert lifecycle.index('scheduler_stop') < lifecycle.index('db_close')
  272. @pytest.mark.asyncio
  273. async def test_http_lifespan_uses_common_environment(lifecycle, monkeypatch):
  274. import main
  275. def env(**kwargs):
  276. assert kwargs == {'scheduler': True, 'business_jobs': True}
  277. return RuntimeEnvironment(**kwargs, prewarm=False, billing_retry=False)
  278. monkeypatch.setattr(main, 'RuntimeEnvironment', env)
  279. async with main.app.router.lifespan_context(main.app):
  280. assert 'scheduler_start' in lifecycle
  281. assert 'db_close' not in lifecycle
  282. assert lifecycle[-4:] == ['scheduler_stop', 'invalidate', 'checkpoint_close', 'db_close']
  283. def test_environment_reopens_across_event_loops(lifecycle):
  284. env = RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False)
  285. async def run():
  286. async with env:
  287. pass
  288. asyncio.run(run())
  289. asyncio.run(run())
  290. assert lifecycle.count('checkpoint') == 2
  291. assert lifecycle.count('db_close') == 2
  292. assert 'recover' not in lifecycle