scheduler.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. """通用任务调度器 —— CAS 锁、并发控制、超时检测、后台执行
  2. 重构要点(v2):
  3. - 不再继承 TaskHandler(scheduler 不是 handler,是编排层)
  4. - 接收 TaskRegistry 实例,消除全局 _TASK_HANDLER_REGISTRY
  5. - 使用结构化 LogRecord 替代自由 dict
  6. """
  7. import asyncio
  8. import json
  9. import time
  10. from datetime import datetime, timedelta
  11. from typing import Any, Callable, Dict, List, Optional
  12. from supply.domain.task.config import TaskConfig, TaskStatus, get_default_task_config
  13. from supply.domain.task.base import TaskRegistry
  14. from supply.domain.task.exceptions import (
  15. TaskConcurrencyError,
  16. TaskValidationError,
  17. )
  18. from supply.infra import TaskScheduleResponse
  19. AlertCallback = Callable[[str, dict], Any]
  20. def _validate_table_name(table_name: str) -> str:
  21. cleaned = table_name.strip().replace(" ", "_")
  22. if not cleaned or "--" in cleaned or ";" in cleaned.lower():
  23. raise TaskValidationError(f"非法的表名: {table_name}")
  24. return cleaned
  25. def _validate_task_name(task_name: Any) -> str:
  26. task_name = str(task_name).strip()
  27. if not task_name:
  28. raise TaskValidationError("task_name 不能为空")
  29. return task_name
  30. def _format_error(e: Exception) -> str:
  31. import traceback
  32. return "".join(
  33. traceback.format_exception(type(e), e, e.__traceback__)
  34. )[-1000:]
  35. class TaskScheduler:
  36. """通用任务调度器 —— CAS 锁、并发控制、超时检测、后台执行
  37. 使用方式:
  38. registry = TaskRegistry()
  39. # ... 注册 handler ...
  40. scheduler = TaskScheduler(
  41. data={"task_name": "my_crawl", "date_string": "2026-06-30"},
  42. registry=registry,
  43. log_service=log,
  44. db_client=db,
  45. trace_id="trace-123",
  46. task_configs={"my_crawl": TaskConfig(timeout=600, max_concurrent=3)},
  47. table_name="my_task_table",
  48. alert_callback=my_alert_func,
  49. )
  50. result = await scheduler.deal()
  51. """
  52. def __init__(
  53. self,
  54. data: dict,
  55. registry: TaskRegistry,
  56. *,
  57. log_service: Any = None,
  58. db_client: Any = None,
  59. trace_id: str = "",
  60. task_configs: Optional[Dict[str, TaskConfig]] = None,
  61. table_name: str = "task_manager",
  62. alert_callback: Optional[AlertCallback] = None,
  63. ):
  64. self.data = data
  65. self.registry = registry
  66. self.log_service = log_service
  67. self.db_client = db_client
  68. self.trace_id = trace_id
  69. self.table = _validate_table_name(table_name)
  70. self.task_configs = task_configs or {}
  71. self._alert_callback = alert_callback
  72. # ==================== 日志 ====================
  73. async def _log(self, event: str, **kwargs) -> None:
  74. """结构化日志推送"""
  75. if self.log_service is None:
  76. return
  77. try:
  78. from supply.infra.observability.log_schema import (
  79. LogRecord,
  80. LogLevel,
  81. LogCategory,
  82. )
  83. record = LogRecord(
  84. level=LogLevel.INFO,
  85. category=LogCategory.TASK,
  86. event=event,
  87. trace_id=self.trace_id,
  88. task_name=self.data.get("task_name", ""),
  89. extra=kwargs,
  90. )
  91. await self.log_service.log(record)
  92. except Exception:
  93. pass
  94. async def _alert(self, title: str, detail: dict) -> None:
  95. if self._alert_callback:
  96. try:
  97. await self._alert_callback(title, detail)
  98. except Exception:
  99. pass
  100. def get_task_config(self, task_name: str) -> TaskConfig:
  101. return self.task_configs.get(task_name, get_default_task_config())
  102. # ==================== 数据库操作 ====================
  103. async def _insert_or_ignore_task(self, task_name: str, date_str: str) -> None:
  104. query = f"""
  105. INSERT IGNORE INTO {self.table}
  106. (date_string, task_name, start_timestamp, task_status, trace_id, data)
  107. VALUES (%s, %s, %s, %s, %s, %s)
  108. """
  109. await self.db_client.async_save(
  110. query=query,
  111. params=(
  112. date_str,
  113. task_name,
  114. int(time.time()),
  115. TaskStatus.INIT,
  116. self.trace_id,
  117. json.dumps(self.data, ensure_ascii=False),
  118. ),
  119. )
  120. async def _try_lock_task(self) -> bool:
  121. query = f"""
  122. UPDATE {self.table}
  123. SET task_status = %s
  124. WHERE trace_id = %s AND task_status = %s
  125. """
  126. result = await self.db_client.async_save(
  127. query=query,
  128. params=(TaskStatus.PROCESSING, self.trace_id, TaskStatus.INIT),
  129. )
  130. return bool(result)
  131. async def _release_task(self, status: int) -> None:
  132. query = f"""
  133. UPDATE {self.table}
  134. SET task_status = %s, finish_timestamp = %s
  135. WHERE trace_id = %s AND task_status = %s
  136. """
  137. await self.db_client.async_save(
  138. query=query,
  139. params=(status, int(time.time()), self.trace_id, TaskStatus.PROCESSING),
  140. )
  141. async def _get_processing_tasks(self, task_name: str) -> List[Dict[str, Any]]:
  142. query = f"""
  143. SELECT trace_id, start_timestamp, data
  144. FROM {self.table}
  145. WHERE task_status = %s AND task_name = %s
  146. """
  147. rows = await self.db_client.async_fetch(
  148. query=query,
  149. params=(TaskStatus.PROCESSING, task_name),
  150. )
  151. return rows or []
  152. # ==================== 并发/超时检查 ====================
  153. async def _check_task_concurrency_and_timeout(self, task_name: str) -> None:
  154. processing_tasks = await self._get_processing_tasks(task_name)
  155. if not processing_tasks:
  156. return
  157. config = self.get_task_config(task_name)
  158. current_time = int(time.time())
  159. # 超时检测:自动释放超时任务
  160. timeout_tasks = [
  161. t
  162. for t in processing_tasks
  163. if current_time - t["start_timestamp"] > config.timeout
  164. ]
  165. if timeout_tasks:
  166. await self._log(
  167. "task_timeout_detected",
  168. timeout_count=len(timeout_tasks),
  169. timeout_traces=[t["trace_id"] for t in timeout_tasks],
  170. )
  171. await self._alert(
  172. title=f"Task Timeout Alert: {task_name}",
  173. detail={
  174. "task_name": task_name,
  175. "timeout_count": len(timeout_tasks),
  176. "timeout_threshold": config.timeout,
  177. "timeout_tasks": [
  178. {
  179. "trace_id": t["trace_id"],
  180. "running_time": current_time - t["start_timestamp"],
  181. }
  182. for t in timeout_tasks
  183. ],
  184. },
  185. )
  186. for t in timeout_tasks:
  187. await self._force_release_task(t["trace_id"], TaskStatus.FAILED)
  188. # 并发限制
  189. active_tasks = [
  190. t
  191. for t in processing_tasks
  192. if current_time - t["start_timestamp"] <= config.timeout
  193. ]
  194. if len(active_tasks) >= config.max_concurrent:
  195. await self._log(
  196. "task_concurrency_limit",
  197. current_count=len(active_tasks),
  198. max_concurrent=config.max_concurrent,
  199. )
  200. await self._alert(
  201. title=f"Task Concurrency Limit: {task_name}",
  202. detail={
  203. "task_name": task_name,
  204. "current_count": len(active_tasks),
  205. "max_concurrent": config.max_concurrent,
  206. "active_tasks": [t["trace_id"] for t in active_tasks],
  207. },
  208. )
  209. raise TaskConcurrencyError(
  210. f"Task {task_name} has reached max concurrency limit "
  211. f"({len(active_tasks)}/{config.max_concurrent})",
  212. task_name=task_name,
  213. )
  214. # ==================== 任务执行 ====================
  215. async def _run_with_guard(
  216. self,
  217. task_name: str,
  218. date_str: str,
  219. handler_callable: Callable,
  220. ) -> dict:
  221. """带保护的任务执行: 并发检查 → CAS 锁 → 后台执行 → 释放"""
  222. # 1. 并发/超时检查
  223. try:
  224. await self._check_task_concurrency_and_timeout(task_name)
  225. except TaskConcurrencyError as e:
  226. return TaskScheduleResponse.fail("5005", str(e))
  227. # 2. 创建任务记录并 CAS 获取锁
  228. await self._insert_or_ignore_task(task_name, date_str)
  229. if not await self._try_lock_task():
  230. return TaskScheduleResponse.fail("5001", "Task is already processing")
  231. # 3. 后台执行
  232. async def _task_wrapper():
  233. status = TaskStatus.FAILED
  234. config = self.get_task_config(task_name)
  235. start_time = time.time()
  236. try:
  237. await self._log("task_started")
  238. result = await handler_callable()
  239. status = result if isinstance(result, int) else TaskStatus.SUCCESS
  240. duration = time.time() - start_time
  241. await self._log(
  242. "task_completed",
  243. status=status,
  244. duration_ms=int(duration * 1000),
  245. )
  246. except Exception as e:
  247. duration = time.time() - start_time
  248. error_detail = _format_error(e)
  249. await self._log(
  250. "task_failed",
  251. error=error_detail,
  252. duration_ms=int(duration * 1000),
  253. )
  254. if config.alert_on_failure:
  255. await self._alert(
  256. title=f"Task Failed: {task_name}",
  257. detail={
  258. "task_name": task_name,
  259. "trace_id": self.trace_id,
  260. "error": error_detail,
  261. "duration": duration,
  262. },
  263. )
  264. finally:
  265. await self._release_task(status)
  266. asyncio.create_task(_task_wrapper(), name=f"{task_name}_{self.trace_id}")
  267. return TaskScheduleResponse.success(
  268. task_name=task_name,
  269. data={
  270. "code": 0,
  271. "message": "Task started successfully",
  272. "trace_id": self.trace_id,
  273. },
  274. )
  275. # ==================== 任务管理接口 ====================
  276. async def get_task_status(self, trace_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
  277. tid = trace_id or self.trace_id
  278. query = f"SELECT * FROM {self.table} WHERE trace_id = %s"
  279. return await self.db_client.async_fetch_one(query, params=(tid,))
  280. async def cancel_task(self, trace_id: Optional[str] = None) -> bool:
  281. tid = trace_id or self.trace_id
  282. query = f"""
  283. UPDATE {self.table}
  284. SET task_status = %s, finish_timestamp = %s
  285. WHERE trace_id = %s AND task_status IN (%s, %s)
  286. """
  287. result = await self.db_client.async_save(
  288. query,
  289. params=(
  290. TaskStatus.FAILED,
  291. int(time.time()),
  292. tid,
  293. TaskStatus.INIT,
  294. TaskStatus.PROCESSING,
  295. ),
  296. )
  297. if result:
  298. await self._log("task_cancelled", trace_id=tid)
  299. return bool(result)
  300. async def retry_task(self, trace_id: Optional[str] = None) -> bool:
  301. tid = trace_id or self.trace_id
  302. query = f"""
  303. UPDATE {self.table}
  304. SET task_status = %s, start_timestamp = %s, finish_timestamp = NULL
  305. WHERE trace_id = %s
  306. """
  307. result = await self.db_client.async_save(
  308. query, params=(TaskStatus.INIT, int(time.time()), tid)
  309. )
  310. if result:
  311. await self._log("task_retried", trace_id=tid)
  312. return bool(result)
  313. async def _force_release_task(self, trace_id: str, status: int) -> None:
  314. query = f"""
  315. UPDATE {self.table}
  316. SET task_status = %s, finish_timestamp = %s
  317. WHERE trace_id = %s
  318. """
  319. await self.db_client.async_save(
  320. query, params=(status, int(time.time()), trace_id)
  321. )
  322. await self._log("task_force_released", trace_id=trace_id, status=status)
  323. # ==================== 主入口 ====================
  324. async def deal(self) -> dict:
  325. """任务调度主入口"""
  326. task_name = self.data.get("task_name")
  327. if not task_name:
  328. return TaskScheduleResponse.fail("4003", "task_name is required")
  329. try:
  330. task_name = _validate_task_name(task_name)
  331. except TaskValidationError as e:
  332. return TaskScheduleResponse.fail("4003", str(e))
  333. date_str = self.data.get("date_string") or (
  334. datetime.utcnow() + timedelta(hours=8)
  335. ).strftime("%Y-%m-%d")
  336. # 从 TaskRegistry 查找 handler
  337. handler_cls = self.registry.get(task_name)
  338. if handler_cls is None:
  339. return TaskScheduleResponse.fail(
  340. "4001",
  341. f"Unknown task: {task_name}. "
  342. f"Available tasks: {', '.join(self.registry.list_names())}",
  343. )
  344. # 实例化 handler
  345. handler = handler_cls(log_service=self.log_service, db_client=self.db_client)
  346. return await self._run_with_guard(
  347. task_name,
  348. date_str,
  349. lambda: handler.execute(
  350. type("Task", (), {"task_name": task_name, "params": self.data, "trace_id": self.trace_id})()
  351. ),
  352. )