task_manager.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. """Task management service for Web-Local system dispatch (P4-M2).
  2. Handles task creation, dispatch, status tracking, progress updates,
  3. and result reception from local simulation executor.
  4. P0-5: When a task completes and has a plan_id, results are automatically
  5. synced to the SimulationResult table so the Plan detail page can display them.
  6. """
  7. import json
  8. import os
  9. import uuid
  10. from datetime import datetime
  11. from typing import Dict, List, Optional, Any
  12. from pathlib import Path
  13. from ..database import SessionLocal
  14. from ..models.task import Task
  15. from ..models.simulation_plan import SimulationPlan
  16. from ..models.simulation_result import SimulationResult
  17. from ..metrics_constants import METRIC_KEYS
  18. class TaskManager:
  19. """Manages simulation tasks between Web and Local systems."""
  20. TASK_STATUSES = [
  21. "pending", # Created, waiting for dispatch
  22. "dispatched", # Sent to local executor
  23. "running", # Local executor is running
  24. "completed", # All points completed successfully
  25. "failed", # Task failed
  26. "cancelled", # User cancelled
  27. ]
  28. def __init__(self, output_dir: Optional[str] = None):
  29. self.output_dir = output_dir or os.path.join(
  30. os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
  31. "output", "tasks"
  32. )
  33. os.makedirs(self.output_dir, exist_ok=True)
  34. def create_task(
  35. self,
  36. plan_id: Optional[int],
  37. plan_data: Dict[str, Any],
  38. parameters: List[Dict[str, Any]],
  39. task_name: Optional[str] = None,
  40. priority: int = 5,
  41. created_by: str = "web",
  42. task_type: str = "scan",
  43. loop_id: Optional[str] = None,
  44. batch_id: Optional[int] = None,
  45. point_ids: Optional[List] = None,
  46. dynamic: bool = False,
  47. thermal_mode: str = "steady",
  48. ) -> Dict[str, Any]:
  49. """Create a new simulation task.
  50. Args:
  51. plan_id: Associated plan ID (optional)
  52. plan_data: Full plan data (boundary conditions, topology, etc.)
  53. parameters: List of parameter sets to simulate
  54. task_name: Optional task name
  55. priority: Task priority (1-10, higher = more urgent)
  56. created_by: Creator identifier
  57. thermal_mode: Thermal simulation mode for every point of this task.
  58. "off" (electromagnetic only) / "steady" (EM + steady-state
  59. thermal, default) / "coupled" (magnetic-thermal coupled).
  60. Returns:
  61. Created task dict
  62. """
  63. task_uuid = str(uuid.uuid4())[:8]
  64. task_name = task_name or f"task_{task_uuid}"
  65. # Validate thermal_mode (task-level switch, see plan/executor docs).
  66. _valid_thermal_modes = ("off", "steady", "coupled")
  67. if thermal_mode not in _valid_thermal_modes:
  68. raise ValueError(
  69. "thermal_mode must be one of %s, got %r"
  70. % (_valid_thermal_modes, thermal_mode)
  71. )
  72. task_dir = os.path.join(self.output_dir, f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{task_name}")
  73. os.makedirs(task_dir, exist_ok=True)
  74. # Write task file for local executor
  75. task_file = os.path.join(task_dir, "task.json")
  76. task_payload = {
  77. "task_id": task_uuid,
  78. "task_name": task_name,
  79. "plan_id": plan_id,
  80. "plan_data": plan_data,
  81. "parameters": parameters,
  82. "priority": priority,
  83. "created_at": datetime.now().isoformat(),
  84. "total_points": len(parameters),
  85. "task_type": task_type,
  86. "loop_id": loop_id,
  87. "batch_id": batch_id,
  88. "point_ids": point_ids or [],
  89. "dynamic": bool(dynamic),
  90. "thermal_mode": thermal_mode,
  91. }
  92. with open(task_file, "w", encoding="utf-8") as f:
  93. json.dump(task_payload, f, ensure_ascii=False, indent=2)
  94. # Save to database
  95. with SessionLocal() as db:
  96. db_task = Task(
  97. task_id=task_uuid,
  98. task_name=task_name,
  99. plan_id=plan_id,
  100. status="pending",
  101. priority=priority,
  102. total_points=len(parameters),
  103. completed_points=0,
  104. task_dir=task_dir,
  105. task_file=task_file,
  106. created_by=created_by,
  107. created_at=datetime.now(),
  108. task_type=task_type,
  109. loop_id=loop_id,
  110. batch_id=batch_id,
  111. point_ids=json.dumps(point_ids or [], ensure_ascii=False),
  112. dynamic=1 if dynamic else 0,
  113. )
  114. db.add(db_task)
  115. db.commit()
  116. db.refresh(db_task)
  117. return self._task_to_dict(db_task)
  118. def dispatch_task(self, task_id: str) -> Dict[str, Any]:
  119. """Mark task as dispatched and ready for local executor.
  120. Uses an atomic conditional UPDATE so exactly one executor instance
  121. can claim a pending task; a concurrent second claim gets a ValueError.
  122. Args:
  123. task_id: Task UUID
  124. Returns:
  125. Updated task dict
  126. Raises:
  127. ValueError: task not found, or already claimed / not pending.
  128. """
  129. from sqlalchemy import update
  130. with SessionLocal() as db:
  131. task = db.query(Task).filter(Task.task_id == task_id).first()
  132. if not task:
  133. raise ValueError(f"Task {task_id} not found")
  134. result = db.execute(
  135. update(Task)
  136. .where(Task.task_id == task_id, Task.status == "pending")
  137. .values(status="dispatched", dispatched_at=datetime.now())
  138. )
  139. db.commit()
  140. if result.rowcount == 0:
  141. # another instance already claimed it, or the state moved on
  142. raise ValueError(
  143. "Task %s is not pending (status: %s)"
  144. % (task_id, task.status)
  145. )
  146. db.refresh(task)
  147. return self._task_to_dict(task)
  148. def update_progress(
  149. self,
  150. task_id: str,
  151. current_point: int,
  152. total_points: Optional[int] = None,
  153. current_params: Optional[Dict[str, Any]] = None,
  154. elapsed_time: Optional[float] = None,
  155. ) -> Dict[str, Any]:
  156. """Update task progress from local executor.
  157. Args:
  158. task_id: Task UUID
  159. current_point: Current point index (0-based)
  160. total_points: Total points (optional, will use stored value)
  161. current_params: Current parameter values being simulated
  162. elapsed_time: Elapsed time in seconds
  163. Returns:
  164. Updated task dict
  165. """
  166. with SessionLocal() as db:
  167. task = db.query(Task).filter(Task.task_id == task_id).first()
  168. if not task:
  169. raise ValueError(f"Task {task_id} not found")
  170. if task.status in ("dispatched", "running"):
  171. task.status = "running"
  172. task.started_at = task.started_at or datetime.now()
  173. task.completed_points = current_point
  174. if total_points:
  175. task.total_points = total_points
  176. # Update progress metadata
  177. progress_data = {
  178. "current_point": current_point,
  179. "current_params": current_params,
  180. "elapsed_time": elapsed_time,
  181. "updated_at": datetime.now().isoformat(),
  182. }
  183. existing_progress = json.loads(task.progress_data or "{}")
  184. existing_progress.update(progress_data)
  185. task.progress_data = json.dumps(existing_progress, ensure_ascii=False)
  186. db.commit()
  187. db.refresh(task)
  188. return self._task_to_dict(task)
  189. def report_results(
  190. self,
  191. task_id: str,
  192. results: List[Dict[str, Any]],
  193. metrics: Optional[Dict[str, Any]] = None,
  194. logs: Optional[str] = None,
  195. duration: Optional[float] = None,
  196. status: str = "completed",
  197. ) -> Dict[str, Any]:
  198. """Report final results from local executor.
  199. Args:
  200. task_id: Task UUID
  201. results: List of simulation result dicts
  202. metrics: Aggregated metrics
  203. logs: Program logs
  204. duration: Total duration in seconds
  205. status: Final status (completed/failed)
  206. Returns:
  207. Updated task dict
  208. """
  209. with SessionLocal() as db:
  210. task = db.query(Task).filter(Task.task_id == task_id).first()
  211. if not task:
  212. raise ValueError(f"Task {task_id} not found")
  213. task.status = status
  214. task.completed_at = datetime.now()
  215. task.completed_points = len(results)
  216. if duration:
  217. task.duration = duration
  218. # Save results to file
  219. results_file = os.path.join(task.task_dir, "results.json")
  220. with open(results_file, "w", encoding="utf-8") as f:
  221. json.dump({"results": results, "metrics": metrics, "logs": logs}, f, ensure_ascii=False, indent=2)
  222. task.results_file = results_file
  223. if metrics:
  224. task.result_metrics = json.dumps(metrics, ensure_ascii=False)
  225. db.commit()
  226. db.refresh(task)
  227. # P0-5: Auto-sync results to plan's SimulationResult table
  228. plan_id = getattr(task, 'plan_id', None)
  229. if plan_id is not None:
  230. self._sync_results_to_plan(plan_id, results)
  231. return self._task_to_dict(task)
  232. def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
  233. """Get task by ID."""
  234. with SessionLocal() as db:
  235. task = db.query(Task).filter(Task.task_id == task_id).first()
  236. if not task:
  237. return None
  238. return self._task_to_dict(task)
  239. def list_tasks(
  240. self,
  241. status: Optional[str] = None,
  242. plan_id: Optional[int] = None,
  243. limit: int = 50,
  244. offset: int = 0,
  245. ) -> Dict[str, Any]:
  246. """List tasks with filters."""
  247. with SessionLocal() as db:
  248. query = db.query(Task)
  249. if status:
  250. query = query.filter(Task.status == status)
  251. if plan_id:
  252. query = query.filter(Task.plan_id == plan_id)
  253. total = query.count()
  254. tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
  255. return {
  256. "total": total,
  257. "limit": limit,
  258. "offset": offset,
  259. "tasks": [self._task_to_dict(t) for t in tasks],
  260. }
  261. def cancel_task(self, task_id: str) -> Dict[str, Any]:
  262. """Cancel a pending or running task."""
  263. with SessionLocal() as db:
  264. task = db.query(Task).filter(Task.task_id == task_id).first()
  265. if not task:
  266. raise ValueError(f"Task {task_id} not found")
  267. if task.status in ("completed", "failed", "cancelled"):
  268. raise ValueError(f"Task {task_id} already finished (status: {task.status})")
  269. task.status = "cancelled"
  270. task.completed_at = datetime.now()
  271. db.commit()
  272. db.refresh(task)
  273. return self._task_to_dict(task)
  274. def delete_task(self, task_id: str) -> None:
  275. """Delete a finished task (terminal status only) and its task file.
  276. Only completed/failed/cancelled tasks may be deleted; active tasks
  277. must be cancelled first so a running executor is never orphaned.
  278. """
  279. with SessionLocal() as db:
  280. task = db.query(Task).filter(Task.task_id == task_id).first()
  281. if not task:
  282. raise ValueError(f"Task {task_id} not found")
  283. if task.status not in ("completed", "failed", "cancelled"):
  284. raise ValueError(
  285. f"Task {task_id} is {task.status}; cancel it before deleting"
  286. )
  287. task_file = task.task_file
  288. db.delete(task)
  289. db.commit()
  290. # Best-effort removal of the on-disk task file after the row is gone.
  291. if task_file and os.path.exists(task_file):
  292. try:
  293. os.remove(task_file)
  294. except OSError:
  295. pass
  296. def get_task_results(self, task_id: str) -> Optional[Dict[str, Any]]:
  297. """Get task results file content."""
  298. task = self.get_task(task_id)
  299. if not task or not task.get("results_file"):
  300. return None
  301. if not os.path.exists(task["results_file"]):
  302. return None
  303. with open(task["results_file"], "r", encoding="utf-8") as f:
  304. return json.load(f)
  305. def _task_to_dict(self, task: Task) -> Dict[str, Any]:
  306. """Convert Task ORM object to dict."""
  307. result = {
  308. "id": task.id,
  309. "task_id": task.task_id,
  310. "task_name": task.task_name,
  311. "plan_id": task.plan_id,
  312. "status": task.status,
  313. "priority": task.priority,
  314. "total_points": task.total_points,
  315. "completed_points": task.completed_points,
  316. "progress": round((task.completed_points / task.total_points * 100), 1) if task.total_points else 0,
  317. "task_dir": task.task_dir,
  318. "task_file": task.task_file,
  319. "results_file": task.results_file,
  320. "created_by": task.created_by,
  321. "created_at": task.created_at.isoformat() if task.created_at else None,
  322. "dispatched_at": task.dispatched_at.isoformat() if task.dispatched_at else None,
  323. "started_at": task.started_at.isoformat() if task.started_at else None,
  324. "completed_at": task.completed_at.isoformat() if task.completed_at else None,
  325. "duration": task.duration,
  326. "task_type": task.task_type or "scan",
  327. "loop_id": task.loop_id,
  328. "batch_id": task.batch_id,
  329. "point_ids": json.loads(task.point_ids) if task.point_ids else [],
  330. "dynamic": bool(task.dynamic),
  331. }
  332. if task.progress_data:
  333. try:
  334. result["progress_data"] = json.loads(task.progress_data)
  335. except (json.JSONDecodeError, Exception):
  336. result["progress_data"] = None
  337. if task.result_metrics:
  338. try:
  339. result["result_metrics"] = json.loads(task.result_metrics)
  340. except (json.JSONDecodeError, Exception):
  341. result["result_metrics"] = None
  342. return result
  343. def _sync_results_to_plan(self, plan_id: int, results: List[Dict[str, Any]]) -> None:
  344. """Sync task results to SimulationResult table (P0-5).
  345. Clears existing results for this plan and inserts new ones.
  346. Each result has params (scan params + fixed params) and metrics.
  347. """
  348. try:
  349. with SessionLocal() as db:
  350. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  351. if not plan:
  352. return
  353. # Clear existing results
  354. db.query(SimulationResult).filter(SimulationResult.plan_id == plan_id).delete()
  355. metric_keys = set(METRIC_KEYS)
  356. for idx, r in enumerate(results):
  357. params = r.get("params", {}) or {}
  358. # Metrics may be at top level or under "metrics" key
  359. metrics = r.get("metrics", {}) or {}
  360. if not metrics:
  361. # Extract metric fields from top level
  362. for k, v in r.items():
  363. if k in metric_keys and isinstance(v, (int, float)):
  364. metrics[k] = float(v)
  365. status = r.get("status", "OK")
  366. solve_time = float(r.get("solve_time_s", r.get("seconds", 0)) or 0)
  367. error_msg = r.get("error", "") or ""
  368. result = SimulationResult(
  369. plan_id=plan_id,
  370. run_index=idx + 1,
  371. status=status,
  372. solve_time_s=solve_time,
  373. error_message=error_msg[:500] if error_msg else "",
  374. )
  375. result.set_params({k: float(v) for k, v in params.items() if isinstance(v, (int, float))})
  376. result.set_metrics({k: float(v) for k, v in metrics.items() if isinstance(v, (int, float))})
  377. db.add(result)
  378. # Update plan status based on results
  379. ok_count = sum(1 for r in results if r.get("status") == "OK")
  380. if ok_count == len(results) and len(results) > 0:
  381. plan.status = "completed"
  382. elif ok_count > 0:
  383. plan.status = "completed"
  384. elif len(results) > 0:
  385. plan.status = "failed"
  386. db.commit()
  387. except Exception as e:
  388. # Sync failure should not break task completion
  389. print(f"[TaskManager] Failed to sync results to plan {plan_id}: {e}")
  390. # ------------------------------------------------------------------
  391. # Overview & Executor monitoring (P1-9)
  392. # ------------------------------------------------------------------
  393. def get_overview(self) -> Dict[str, Any]:
  394. """Get task overview statistics for monitoring dashboard."""
  395. with SessionLocal() as db:
  396. total = db.query(Task).count()
  397. pending = db.query(Task).filter(Task.status == "pending").count()
  398. dispatched = db.query(Task).filter(Task.status == "dispatched").count()
  399. running = db.query(Task).filter(Task.status == "running").count()
  400. completed = db.query(Task).filter(Task.status == "completed").count()
  401. failed = db.query(Task).filter(Task.status == "failed").count()
  402. # Recent tasks (last 20)
  403. recent = (
  404. db.query(Task)
  405. .order_by(Task.created_at.desc())
  406. .limit(20)
  407. .all()
  408. )
  409. recent_list = [self._task_to_dict(t) for t in recent]
  410. # Active (pending + dispatched + running)
  411. active = (
  412. db.query(Task)
  413. .filter(Task.status.in_(["pending", "dispatched", "running"]))
  414. .order_by(Task.priority.desc(), Task.created_at.asc())
  415. .all()
  416. )
  417. active_list = [self._task_to_dict(t) for t in active]
  418. return {
  419. "total": total,
  420. "pending": pending,
  421. "dispatched": dispatched,
  422. "running": running,
  423. "completed": completed,
  424. "failed": failed,
  425. "active_tasks": active_list,
  426. "recent_tasks": recent_list,
  427. }
  428. def executor_heartbeat(self, executor_id: str, status: str = "idle",
  429. current_task: Optional[str] = None,
  430. progress: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
  431. """Record executor heartbeat and return next pending task if available.
  432. Args:
  433. executor_id: Unique executor identifier.
  434. status: executor status (idle / running / error).
  435. current_task: task_id currently being executed, if any.
  436. progress: current progress data.
  437. Returns:
  438. Dict with executor status and optionally a task to claim.
  439. """
  440. now = datetime.now()
  441. # Store heartbeat in memory (simple approach)
  442. if not hasattr(self, "_executors"):
  443. self._executors = {}
  444. self._executors[executor_id] = {
  445. "executor_id": executor_id,
  446. "status": status,
  447. "current_task": current_task,
  448. "last_heartbeat": now.isoformat(),
  449. "progress": progress,
  450. }
  451. return {"status": "ok", "executor_id": executor_id, "server_time": now.isoformat()}
  452. def get_executor_status(self) -> Dict[str, Any]:
  453. """Get all registered executors and their status."""
  454. if not hasattr(self, "_executors"):
  455. self._executors = {}
  456. now = datetime.now()
  457. executors = []
  458. for eid, info in self._executors.items():
  459. last_hb = info.get("last_heartbeat")
  460. online = False
  461. if last_hb:
  462. try:
  463. hb_time = datetime.fromisoformat(last_hb)
  464. online = (now - hb_time).total_seconds() < 60
  465. except Exception:
  466. online = False
  467. executors.append({**info, "online": online})
  468. return {"executors": executors, "count": len(executors)}
  469. # Global singleton
  470. _task_manager: Optional[TaskManager] = None
  471. def get_task_manager() -> TaskManager:
  472. """Get or create global TaskManager singleton."""
  473. global _task_manager
  474. if _task_manager is None:
  475. _task_manager = TaskManager()
  476. return _task_manager