batch_scheduler.py 8.4 KB

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