concurrency.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. """长任务并发闸。
  2. 超限**直接拒绝(429),不排队**。这是刻意的取舍:LLM 长任务动辄跑几分钟,
  3. 排队意味着用户盯着一个不动的界面等一个不知道多久才开始的请求;而 429 带
  4. ``retry_after`` 能让前端明确告诉用户「稍后再试」,体验反而更可控。
  5. 两道上限同时生效:
  6. - 全局(``TFAGENTS_MAX_CONCURRENT_TASKS``)保护下游 provider 与自身资源
  7. - 每用户(``TFAGENTS_MAX_USER_TASKS``)防止单个用户占满全局额度
  8. 计数是**进程内**的,所以这道闸只在 ``workers=1`` 下有效;多 worker 时每个
  9. 进程各算各的,实际并发会翻倍。多进程部署需要把计数挪到 Redis/MySQL。
  10. """
  11. from __future__ import annotations
  12. import threading
  13. import time
  14. from contextlib import contextmanager
  15. from typing import Any
  16. import config
  17. from obs import get_logger
  18. logger = get_logger(__name__)
  19. class TooManyTasksError(RuntimeError):
  20. """并发已满。携带建议的重试间隔,供 429 响应的 retry_after 使用。"""
  21. def __init__(self, message: str, scope: str, retry_after: int = 10) -> None:
  22. self.scope = scope
  23. self.retry_after = retry_after
  24. super().__init__(message)
  25. class TaskWallTimeoutError(RuntimeError):
  26. """任务超过墙钟上限。"""
  27. class SessionBusyError(RuntimeError):
  28. """同一会话正在执行或修改。"""
  29. class SessionMissingError(RuntimeError):
  30. """取得执行锁时会话已被删除或不属于当前用户。"""
  31. _sessions: set[str] = set()
  32. class SessionLease:
  33. def __init__(self, session_id: str) -> None:
  34. self.session_id = session_id
  35. self.released = False
  36. def release(self) -> None:
  37. with _state_lock:
  38. if not self.released:
  39. _sessions.discard(self.session_id)
  40. self.released = True
  41. def acquire_session(session_id: str) -> SessionLease:
  42. with _state_lock:
  43. if session_id in _sessions:
  44. raise SessionBusyError("该会话正在执行,请等待结束后重试")
  45. _sessions.add(session_id)
  46. return SessionLease(session_id)
  47. @contextmanager
  48. def session_guard(session_id: str):
  49. lease = acquire_session(session_id)
  50. try:
  51. yield
  52. finally:
  53. lease.release()
  54. class TaskSlot:
  55. """一个已占用的并发位。
  56. 必须成对使用:``acquire_task_slot()`` 拿到后,在 finally 里 ``release()``。
  57. 重复 release 安全(幂等)——SSE 生成器有多条退出路径,很容易重复调用。
  58. """
  59. __slots__ = ("user_name", "acquired_at", "_released", "_lock")
  60. def __init__(self, user_name: str) -> None:
  61. self.user_name = user_name
  62. self.acquired_at = time.time()
  63. self._released = False
  64. self._lock = threading.Lock()
  65. @property
  66. def held_seconds(self) -> float:
  67. return time.time() - self.acquired_at
  68. @property
  69. def released(self) -> bool:
  70. return self._released
  71. def release(self) -> bool:
  72. """释放占位。返回 True 表示本次调用真正释放了(首次调用)。"""
  73. with self._lock:
  74. if self._released:
  75. return False
  76. self._released = True
  77. with _state_lock:
  78. _global_count[0] = max(_global_count[0] - 1, 0)
  79. user_count = _per_user_count.get(self.user_name, 0)
  80. if user_count <= 1:
  81. _per_user_count.pop(self.user_name, None)
  82. else:
  83. _per_user_count[self.user_name] = user_count - 1
  84. logger.debug(
  85. "释放并发位 user=%s 持有时长=%.1fs 剩余全局=%d 剩余用户=%d",
  86. self.user_name, self.held_seconds, _global_count[0],
  87. _per_user_count.get(self.user_name, 0),
  88. )
  89. return True
  90. # 进程内计数。用 list/dict 而不是裸 int,避免到处写 global
  91. _state_lock = threading.Lock()
  92. _global_count = [0]
  93. _per_user_count: dict[str, int] = {}
  94. def acquire_task_slot(user_name: str) -> TaskSlot:
  95. """尝试占一个并发位;超限抛 :class:`TooManyTasksError`。"""
  96. user = user_name or "default"
  97. with _state_lock:
  98. if _global_count[0] >= config.MAX_CONCURRENT_TASKS:
  99. raise TooManyTasksError(
  100. f"服务当前并发任务已达上限({config.MAX_CONCURRENT_TASKS}),请稍后重试",
  101. scope="global",
  102. )
  103. current_user = _per_user_count.get(user, 0)
  104. if current_user >= config.MAX_USER_TASKS:
  105. raise TooManyTasksError(
  106. f"您的并发任务已达上限({config.MAX_USER_TASKS}),请等待当前任务结束后重试",
  107. scope="user",
  108. )
  109. _global_count[0] += 1
  110. _per_user_count[user] = current_user + 1
  111. logger.debug(
  112. "占用并发位 user=%s 全局=%d/%d 用户=%d/%d",
  113. user, _global_count[0], config.MAX_CONCURRENT_TASKS,
  114. _per_user_count[user], config.MAX_USER_TASKS,
  115. )
  116. return TaskSlot(user)
  117. def stats() -> dict[str, Any]:
  118. """当前并发占用快照,供 /health 展示。"""
  119. with _state_lock:
  120. return {
  121. "global_active": _global_count[0],
  122. "global_limit": config.MAX_CONCURRENT_TASKS,
  123. "user_limit": config.MAX_USER_TASKS,
  124. "users": dict(_per_user_count),
  125. "wall_timeout_seconds": config.TASK_WALL_TIMEOUT,
  126. }
  127. def reset_for_tests() -> None:
  128. """仅供测试:清零计数。"""
  129. with _state_lock:
  130. _global_count[0] = 0
  131. _per_user_count.clear()
  132. _sessions.clear()