test_environment.py 13 KB

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