batch_scheduler.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """Batch task scheduler for multi-task simulation (P4-M3).
  2. Priority queue, parallel execution, checkpoint/resume, real-time stats.
  3. """
  4. import json
  5. import os
  6. import threading
  7. from datetime import datetime
  8. from typing import Any, Callable, Dict, List, Optional
  9. from collections import deque
  10. from ..services.task_contract import merge_adaptive_fields
  11. class BatchScheduler:
  12. """Batch simulation task scheduler with priority queue."""
  13. def __init__(self, state_file: Optional[str] = None):
  14. self._queue: deque = deque()
  15. self._running: Dict[str, Dict[str, Any]] = {}
  16. self._completed: List[Dict[str, Any]] = []
  17. # B1 fix: use RLock (reentrant) because _notify_callbacks -> get_statistics
  18. # acquires the same lock, and _notify_callbacks is called from within
  19. # locked sections (get_next_task, cancel_task).
  20. self._lock = threading.RLock()
  21. self._max_parallel = int(os.environ.get("MAX_PARALLEL_TASKS", "2"))
  22. self._state_file = state_file or os.path.join(
  23. os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
  24. "output", "scheduler_state.json"
  25. )
  26. self._callbacks: List[Callable] = []
  27. self._load_state()
  28. def add_task(self, task_id: str, task_name: str, priority: int = 5,
  29. parameters: Optional[List[Dict[str, Any]]] = None,
  30. plan_data: Optional[Dict[str, Any]] = None,
  31. wait_for: Optional[List[str]] = None,
  32. task_type: str = "scan",
  33. loop_id: Optional[str] = None,
  34. batch_id: Optional[int] = None,
  35. point_ids: Optional[List] = None,
  36. dynamic: bool = False) -> Dict[str, Any]:
  37. task_info = {
  38. "task_id": task_id, "task_name": task_name, "priority": priority,
  39. "status": "queued", "parameters": parameters or [],
  40. "plan_data": plan_data or {}, "wait_for": wait_for or [],
  41. "enqueued_at": datetime.now().isoformat(),
  42. "started_at": None, "completed_at": None,
  43. "current_point": 0, "total_points": len(parameters or []),
  44. "error": None,
  45. }
  46. # P3-M4: adaptive-batch fields align scheduler tasks with Task ORM.
  47. task_info = merge_adaptive_fields(
  48. task_info, task_type=task_type, loop_id=loop_id,
  49. batch_id=batch_id, point_ids=point_ids, dynamic=dynamic,
  50. )
  51. with self._lock:
  52. inserted = False
  53. for i, existing in enumerate(self._queue):
  54. if priority > existing["priority"]:
  55. self._queue.insert(i, task_info)
  56. inserted = True
  57. break
  58. if not inserted:
  59. self._queue.append(task_info)
  60. self._save_state_locked()
  61. self._notify_callbacks()
  62. return task_info
  63. def get_next_task(self) -> Optional[Dict[str, Any]]:
  64. with self._lock:
  65. if len(self._running) >= self._max_parallel:
  66. return None
  67. for task in list(self._queue):
  68. if task["wait_for"]:
  69. waiting = set(task["wait_for"])
  70. done = {t["task_id"] for t in self._completed if t["status"] == "completed"}
  71. if not waiting.issubset(done):
  72. continue
  73. self._queue.remove(task)
  74. task["status"] = "running"
  75. task["started_at"] = datetime.now().isoformat()
  76. self._running[task["task_id"]] = task
  77. self._save_state_locked()
  78. self._notify_callbacks()
  79. return task
  80. return None
  81. def update_task_progress(self, task_id: str, current_point: int,
  82. total_points: Optional[int] = None) -> None:
  83. with self._lock:
  84. if task_id in self._running:
  85. self._running[task_id]["current_point"] = current_point
  86. if total_points:
  87. self._running[task_id]["total_points"] = total_points
  88. self._save_state_locked()
  89. self._notify_callbacks()
  90. def complete_task(self, task_id: str, status: str = "completed",
  91. error: Optional[str] = None) -> None:
  92. with self._lock:
  93. if task_id in self._running:
  94. task = self._running.pop(task_id)
  95. task["status"] = status
  96. task["completed_at"] = datetime.now().isoformat()
  97. task["error"] = error
  98. self._completed.append(task)
  99. if len(self._completed) > 100:
  100. self._completed = self._completed[-100:]
  101. self._save_state_locked()
  102. self._notify_callbacks()
  103. def cancel_task(self, task_id: str) -> bool:
  104. with self._lock:
  105. for task in list(self._queue):
  106. if task["task_id"] == task_id:
  107. self._queue.remove(task)
  108. task["status"] = "cancelled"
  109. task["completed_at"] = datetime.now().isoformat()
  110. self._completed.append(task)
  111. self._save_state_locked()
  112. self._notify_callbacks()
  113. return True
  114. if task_id in self._running:
  115. task = self._running.pop(task_id)
  116. task["status"] = "cancelled"
  117. task["completed_at"] = datetime.now().isoformat()
  118. self._completed.append(task)
  119. self._save_state_locked()
  120. self._notify_callbacks()
  121. return True
  122. return False
  123. def get_statistics(self) -> Dict[str, Any]:
  124. with self._lock:
  125. queued = list(self._queue)
  126. running = list(self._running.values())
  127. completed = list(self._completed)
  128. tpq = sum(t["total_points"] for t in queued)
  129. tpr = sum(t["total_points"] for t in running)
  130. cpr = sum(t["current_point"] for t in running)
  131. tc = len([t for t in completed if t["status"] == "completed"])
  132. tf = len([t for t in completed if t["status"] == "failed"])
  133. total_all = tpq + tpr
  134. done_all = cpr + sum(t["total_points"] for t in completed if t["status"] == "completed")
  135. overall = round((done_all / total_all * 100), 1) if total_all > 0 else 0
  136. return {
  137. "queued_count": len(queued), "running_count": len(running),
  138. "completed_count": len(completed), "successful_count": tc,
  139. "failed_count": tf, "max_parallel": self._max_parallel,
  140. "total_points_queued": tpq, "total_points_running": tpr,
  141. "completed_points_running": cpr, "overall_progress": overall,
  142. "queued_tasks": [self._summary(t) for t in queued[:20]],
  143. "running_tasks": [self._summary(t) for t in running],
  144. "recent_completed": [self._summary(t) for t in completed[-10:]],
  145. "timestamp": datetime.now().isoformat(),
  146. }
  147. def _summary(self, task: Dict[str, Any]) -> Dict[str, Any]:
  148. progress = round((task["current_point"] / task["total_points"] * 100), 1) if task["total_points"] > 0 else 0
  149. return {
  150. "task_id": task["task_id"], "task_name": task["task_name"],
  151. "status": task["status"], "priority": task["priority"],
  152. "current_point": task["current_point"], "total_points": task["total_points"],
  153. "progress": progress, "enqueued_at": task.get("enqueued_at"),
  154. "started_at": task.get("started_at"), "completed_at": task.get("completed_at"),
  155. "error": task.get("error"),
  156. "task_type": task.get("task_type", "scan"),
  157. "loop_id": task.get("loop_id"),
  158. "batch_id": task.get("batch_id"),
  159. "point_ids": task.get("point_ids") or [],
  160. "dynamic": bool(task.get("dynamic", False)),
  161. }
  162. def register_callback(self, callback: Callable) -> None:
  163. self._callbacks.append(callback)
  164. def _notify_callbacks(self) -> None:
  165. stats = self.get_statistics()
  166. for cb in self._callbacks:
  167. try:
  168. cb(stats)
  169. except Exception:
  170. pass
  171. def _save_state_locked(self) -> None:
  172. try:
  173. os.makedirs(os.path.dirname(self._state_file), exist_ok=True)
  174. state = {"queue": list(self._queue), "running": self._running,
  175. "completed": self._completed[-50:], "saved_at": datetime.now().isoformat()}
  176. with open(self._state_file, "w", encoding="utf-8") as f:
  177. json.dump(state, f, ensure_ascii=False, indent=2)
  178. except Exception:
  179. pass
  180. def _load_state(self) -> None:
  181. try:
  182. if os.path.exists(self._state_file):
  183. with open(self._state_file, "r", encoding="utf-8") as f:
  184. state = json.load(f)
  185. with self._lock:
  186. self._queue = deque(state.get("queue", []))
  187. for task in state.get("running", {}).values():
  188. task["status"] = "queued"
  189. task["error"] = "Recovered from previous session"
  190. self._queue.append(task)
  191. self._completed = state.get("completed", [])
  192. except Exception:
  193. pass
  194. _scheduler: Optional[BatchScheduler] = None
  195. def get_scheduler() -> BatchScheduler:
  196. global _scheduler
  197. if _scheduler is None:
  198. _scheduler = BatchScheduler()
  199. return _scheduler