environment.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. """网页、独立脚本和集成测试共用的进程级运行环境。"""
  2. from __future__ import annotations
  3. import asyncio
  4. from contextlib import AsyncExitStack
  5. import os
  6. import threading
  7. import time
  8. import config
  9. from agents import agent_memory, agent_registry
  10. from billing import pricing
  11. from db import db_conn, run_store
  12. from extensions.registry import approval_names, load_extensions
  13. from extensions.services import ServiceContainer
  14. from obs import get_logger, setup_logging
  15. logger = get_logger(__name__)
  16. # 底层缓存与连接仍是进程级单例;拒绝嵌套/并行环境,防止一方关闭另一方的资源。
  17. _owner_lock = threading.Lock()
  18. async def _retry_billing(stop: asyncio.Event) -> None:
  19. from billing.meter import retry_pending
  20. while not stop.is_set():
  21. try:
  22. await asyncio.to_thread(retry_pending)
  23. except Exception:
  24. logger.exception("重试待结算流水失败")
  25. try:
  26. await asyncio.wait_for(stop.wait(), 60)
  27. except asyncio.TimeoutError:
  28. pass
  29. async def _stop_billing(stop: asyncio.Event, task: asyncio.Task) -> None:
  30. stop.set()
  31. # 不取消 to_thread:必须等数据库写入完成才能关闭连接。
  32. await task
  33. async def _prewarm() -> None:
  34. ok = await agent_registry.prewarm()
  35. logger.debug("agent 预热%s", "完成" if ok else "失败(将在首次请求时重试)")
  36. async def _join(task: asyncio.Task) -> None:
  37. # 初始化和编译可能在线程中进行,取消协程并不能停止底层工作。
  38. await task
  39. def _check_single_process() -> None:
  40. workers = os.getenv("WEB_CONCURRENCY", "")
  41. try:
  42. count = int(workers) if workers else 1
  43. except ValueError:
  44. count = 1
  45. if count > 1:
  46. logger.warning(
  47. "检测到 WEB_CONCURRENCY=%s:checkpoint / 并发闸 / 调度器 "
  48. "使用进程级状态,生产请用 --workers 1。", workers,
  49. )
  50. class RuntimeEnvironment:
  51. """使用 ``async with`` 管理一次完整生命周期。
  52. 默认加载扩展、建表、初始化定价/checkpoint、恢复中断记录、后台预热和重试计费。
  53. 独立脚本默认不启动调度器;网页入口显式传 ``scheduler=True``。
  54. 同一进程只允许一个活动环境,可在退出后重新进入;退出前调用方必须等待
  55. 自己创建的执行任务结束(或取消并等待收尾),再释放共享资源。
  56. """
  57. def __init__(self, *, scheduler: bool = False, prewarm: bool = True,
  58. billing_retry: bool = True, recover_interrupted: bool = True,
  59. business_jobs: bool = False) -> None:
  60. self.scheduler = scheduler
  61. self.prewarm = prewarm
  62. self.billing_retry = billing_retry
  63. self.recover_interrupted = recover_interrupted
  64. self.business_jobs = business_jobs
  65. self._stack: AsyncExitStack | None = None
  66. async def __aenter__(self) -> RuntimeEnvironment:
  67. if not _owner_lock.acquire(blocking=False):
  68. raise RuntimeError("已有活动的 RuntimeEnvironment,请复用外层环境")
  69. stack = self._stack = AsyncExitStack()
  70. stack.callback(_owner_lock.release)
  71. started = time.perf_counter()
  72. try:
  73. setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
  74. logger.info("运行环境启动", extra={"kv": {"event": "runtime.starting",
  75. "version": config.APP_VERSION, "dialect": config.DB_DIALECT, "model": config.MODEL,
  76. "scheduler": self.scheduler}})
  77. config.log_config_sources()
  78. _check_single_process()
  79. catalog = load_extensions()
  80. approval_names()
  81. logger.info("扩展加载完成", extra={"kv": {"event": "extensions.loaded",
  82. "modules": catalog.modules, "disabled": catalog.disabled_modules,
  83. "tools": len(catalog.tools), "agents": len(catalog.agents)}})
  84. stack.callback(db_conn.close)
  85. db_conn.init_schema()
  86. logger.info("存储就绪 %s", db_conn.health())
  87. pricing.seed_default_pricing()
  88. if self.recover_interrupted:
  89. run_store.recover_interrupted()
  90. # 注册在初始化前,部分初始化失败也会清理。
  91. stack.push_async_callback(agent_memory.close_checkpointer)
  92. agent_registry.invalidate()
  93. init_task = asyncio.create_task(agent_memory.init_checkpointer(), name="runtime-checkpoint-init")
  94. stack.push_async_callback(_join, init_task)
  95. # aiosqlite 在线程中连接;取消调用方时先等初始化结束,再关闭连接。
  96. await asyncio.shield(init_task)
  97. services = ServiceContainer(catalog.services)
  98. stack.push_async_callback(services.close)
  99. task = asyncio.create_task(services.start(), name="runtime-services-init")
  100. stack.push_async_callback(_join, task)
  101. await asyncio.shield(task)
  102. stack.callback(agent_registry.invalidate)
  103. catalog.check_middleware_factories()
  104. if self.prewarm:
  105. task = asyncio.create_task(_prewarm(), name="runtime-prewarm")
  106. stack.push_async_callback(_join, task)
  107. if self.billing_retry:
  108. stop = asyncio.Event()
  109. task = asyncio.create_task(_retry_billing(stop), name="runtime-billing")
  110. stack.push_async_callback(_stop_billing, stop, task)
  111. if self.scheduler:
  112. from scheduler import scheduler
  113. stack.push_async_callback(scheduler.shutdown)
  114. task = asyncio.create_task(scheduler.start(), name="runtime-scheduler-init")
  115. stack.push_async_callback(_join, task)
  116. await asyncio.shield(task)
  117. if self.business_jobs:
  118. from extensions.services import get_service
  119. from runtime.business_tasks import BusinessTaskController
  120. gateway = get_service("shaanxi_gateway") if "shaanxi_gateway" in catalog.services else None
  121. publisher = get_service("result_platform") if "result_platform" in catalog.services else None
  122. controller = BusinessTaskController(gateway, publisher=publisher)
  123. stack.push_async_callback(controller.close)
  124. task = asyncio.create_task(controller.start(), name="runtime-business-init")
  125. stack.push_async_callback(_join, task)
  126. await asyncio.shield(task)
  127. if not config.OPENAI_API_KEY:
  128. logger.warning("OPENAI_API_KEY 未配置,对话会失败——请在 .env 里设置")
  129. logger.info("运行环境就绪", extra={"kv": {"event": "runtime.ready", "scheduler": self.scheduler,
  130. "duration_ms": round((time.perf_counter() - started) * 1000, 1)}})
  131. if self.scheduler and 'main_fan_batches' in catalog.services:
  132. from extensions.services import get_service
  133. batches = get_service('main_fan_batches')
  134. # 先等待所有共享服务、checkpoint 和业务执行器初始化完毕。
  135. # 退出时先停止研判,再关闭结果队列和服务容器;服务工厂可重复 close。
  136. stack.push_async_callback(batches.close)
  137. batches.start_on_startup()
  138. return self
  139. except BaseException as exc:
  140. if isinstance(exc, asyncio.CancelledError):
  141. logger.warning("运行环境启动已取消", extra={"kv": {"event": "runtime.cancelled"}})
  142. else:
  143. logger.exception("运行环境启动失败", extra={"kv": {"event": "runtime.startup_failed"}})
  144. await self._close()
  145. raise
  146. async def _close(self) -> None:
  147. stack = self._stack
  148. if stack is None:
  149. return
  150. # shield 保证取消(含二次取消)时仍完成整条资源清理链。
  151. task = asyncio.create_task(stack.aclose(), name="runtime-close")
  152. cancelled = False
  153. try:
  154. while not task.done():
  155. try:
  156. await asyncio.shield(task)
  157. except asyncio.CancelledError:
  158. cancelled = True
  159. task.result()
  160. finally:
  161. self._stack = None
  162. logger.info("运行环境已关闭", extra={"kv": {"event": "runtime.closed"}})
  163. if cancelled:
  164. raise asyncio.CancelledError
  165. async def __aexit__(self, exc_type, exc, tb) -> None:
  166. await self._close()