batch_scheduler.py 8.6 KB

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