batch.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """有界批量队列、落盘状态及结果保留;由 HTTP lifespan 管理。"""
  2. from __future__ import annotations
  3. from concurrent.futures import ThreadPoolExecutor
  4. from copy import deepcopy
  5. from datetime import datetime, timedelta, timezone
  6. import json
  7. import logging
  8. from pathlib import Path
  9. import re
  10. import shutil
  11. import threading
  12. from typing import BinaryIO
  13. from uuid import uuid4
  14. import zipfile
  15. import config
  16. from . import service
  17. from .service import AlgorithmError
  18. MAX_BATCH_FILES = 20
  19. MAX_BATCH_BYTES = 100 * 1024 * 1024
  20. TERMINAL = {"completed", "partial_failed", "failed", "interrupted"}
  21. ITEM_TERMINAL = {"completed", "failed", "interrupted"}
  22. logger = logging.getLogger(__name__)
  23. def _now() -> str:
  24. return datetime.now(timezone.utc).isoformat()
  25. class BatchManager:
  26. def __init__(self, root: Path, *, parallelism: int | None = None,
  27. max_pending: int | None = None, ttl_hours: int | None = None):
  28. self.root = root.resolve()
  29. self.root.mkdir(parents=True, exist_ok=True)
  30. self.parallelism = parallelism or config.ALGORITHM_MAX_PARALLEL
  31. self.max_pending = max_pending or config.ALGORITHM_MAX_PENDING_IMAGES
  32. self.ttl_hours = ttl_hours or config.ALGORITHM_RESULT_TTL_HOURS
  33. self._lock = threading.RLock()
  34. self._jobs: dict[str, dict] = {}
  35. self._pending = 0
  36. self._closed = False
  37. self._stop = threading.Event()
  38. self._restore()
  39. self.cleanup()
  40. self._pool = ThreadPoolExecutor(max_workers=self.parallelism, thread_name_prefix="curve-batch")
  41. self._cleaner = threading.Thread(target=self._maintain, name="curve-result-cleanup", daemon=True)
  42. self._cleaner.start()
  43. def _save(self, job: dict) -> None:
  44. path = self.root / job["id"] / "job.json"
  45. temporary = path.with_suffix(".tmp")
  46. temporary.write_text(json.dumps(job, ensure_ascii=False, allow_nan=False), encoding="utf-8")
  47. temporary.replace(path)
  48. def _restore(self) -> None:
  49. for directory in self.root.iterdir():
  50. if directory.is_symlink() or not directory.is_dir() or not re.fullmatch(r"[0-9a-f]{32}", directory.name):
  51. continue
  52. path = directory / "job.json"
  53. if not path.exists():
  54. # 创建阶段崩溃留下的目录,没有已提交任务。
  55. shutil.rmtree(directory)
  56. continue
  57. job = json.loads(path.read_text(encoding="utf-8"))
  58. if job["id"] != directory.name:
  59. raise ValueError(f"算法任务目录与记录不符:{directory.name}")
  60. self._jobs[job["id"]] = job
  61. if job["status"] not in TERMINAL:
  62. for item in job["items"]:
  63. if item["status"] not in ITEM_TERMINAL:
  64. self._interrupt(item)
  65. shutil.rmtree(directory / "items" / item["id"], ignore_errors=True)
  66. shutil.rmtree(directory / "inputs", ignore_errors=True)
  67. self._finalize(job["id"], interrupted=True)
  68. @staticmethod
  69. def _interrupt(item: dict) -> None:
  70. item.update(status="interrupted", stage="interrupted", finished_at=_now(),
  71. error={"code": "service_interrupted", "message": "服务已停止,请重新提交此图片"})
  72. def cleanup(self) -> None:
  73. with self._lock:
  74. now = _now()
  75. for job_id, job in list(self._jobs.items()):
  76. if job["status"] in TERMINAL and job.get("expires_at", "9999") <= now:
  77. try:
  78. shutil.rmtree(self.root / job_id)
  79. except FileNotFoundError:
  80. pass
  81. except OSError:
  82. logger.exception("清理算法任务失败:%s", job_id)
  83. continue
  84. del self._jobs[job_id]
  85. def _maintain(self) -> None:
  86. while not self._stop.wait(60):
  87. self.cleanup()
  88. def submit(self, files: list[tuple[str, bytes]], *, detailed: bool = False) -> dict:
  89. service.check_dependencies()
  90. if not 1 <= len(files) <= MAX_BATCH_FILES:
  91. raise AlgorithmError("invalid_batch_size", "每批需上传 1~20 张图片", 422)
  92. if any(len(content) > service.MAX_UPLOAD_BYTES for _, content in files):
  93. raise AlgorithmError("image_too_large", "单张图片不能超过 20 MiB", 413)
  94. if sum(len(content) for _, content in files) > MAX_BATCH_BYTES:
  95. raise AlgorithmError("batch_too_large", "每批图片合计不能超过 100 MiB", 413)
  96. with self._lock:
  97. self.cleanup()
  98. if self._closed:
  99. raise AlgorithmError("algorithm_unavailable", "算法队列正在关闭", 503)
  100. if self._pending + len(files) > self.max_pending:
  101. raise AlgorithmError("algorithm_queue_full", "算法队列已满,请稍后重试", 429)
  102. job_id = uuid4().hex
  103. directory = self.root / job_id
  104. inputs = directory / "inputs"
  105. inputs.mkdir(parents=True)
  106. job = {"id": job_id, "algorithm": "characteristic_curve_recognition", "status": "queued",
  107. "created_at": _now(), "finished_at": None, "expires_at": None,
  108. "detailed": detailed, "items": []}
  109. try:
  110. for index, (filename, content) in enumerate(files, 1):
  111. item_id = f"{index:04d}"
  112. try:
  113. suffix = service.validate_image(content)
  114. except AlgorithmError:
  115. suffix = ".bin" # 格式错误作为单张失败,不拒绝其他图片。
  116. (inputs / (item_id + suffix)).write_bytes(content)
  117. job["items"].append({"id": item_id, "filename": filename.replace("\\", "/").rsplit("/", 1)[-1][:255],
  118. "status": "queued", "stage": "queued", "started_at": None,
  119. "finished_at": None, "error": None, "suffix": suffix})
  120. self._save(job)
  121. except BaseException:
  122. shutil.rmtree(directory, ignore_errors=True)
  123. raise
  124. self._jobs[job_id] = job
  125. self._pending += len(files)
  126. for item in job["items"]:
  127. self._pool.submit(self._run, job_id, item["id"])
  128. return self._snapshot(job)
  129. def _run(self, job_id: str, item_id: str) -> None:
  130. with self._lock:
  131. if self._closed:
  132. return # close() 等活动计算结束后统一将未开始项标记中断。
  133. job = self._jobs[job_id]
  134. item = next(item for item in job["items"] if item["id"] == item_id)
  135. job["status"] = "running"
  136. item.update(status="running", stage="waiting_for_slot", started_at=_now())
  137. self._save(job)
  138. directory = self.root / job_id
  139. image_path = directory / "inputs" / (item_id + item["suffix"])
  140. result_dir = directory / "items" / item_id
  141. stage_path = directory / (item_id + ".stage.json")
  142. error = None
  143. try:
  144. service.validate_image(image_path.read_bytes())
  145. service.execute_image(image_path, result_dir, detailed=job["detailed"],
  146. wait_for_slot=True, stage_path=stage_path)
  147. except AlgorithmError as exc:
  148. error = {"code": exc.code, "message": str(exc)}
  149. except Exception:
  150. logger.exception("批量图片处理失败:%s/%s", job_id, item_id)
  151. error = {"code": "algorithm_failed", "message": "算法处理失败,请通过服务日志排查"}
  152. finally:
  153. image_path.unlink(missing_ok=True)
  154. if error:
  155. shutil.rmtree(result_dir, ignore_errors=True)
  156. with self._lock:
  157. status = "failed" if error else "completed"
  158. item.update(status=status, stage=status, finished_at=_now(), error=error)
  159. self._pending -= 1
  160. finalize = all(value["status"] in ITEM_TERMINAL for value in job["items"])
  161. if finalize:
  162. job["status"] = "packaging"
  163. self._save(job)
  164. if finalize:
  165. self._finalize(job_id)
  166. def _finalize(self, job_id: str, *, interrupted: bool = False) -> None:
  167. with self._lock:
  168. job = deepcopy(self._jobs[job_id])
  169. successes = sum(item["status"] == "completed" for item in job["items"])
  170. job["status"] = ("interrupted" if interrupted else "completed" if successes == len(job["items"])
  171. else "partial_failed" if successes else "failed")
  172. job["finished_at"] = _now()
  173. job["expires_at"] = (datetime.now(timezone.utc) + timedelta(hours=self.ttl_hours)).isoformat()
  174. directory = self.root / job_id
  175. temporary = directory / "results.zip.tmp"
  176. try:
  177. with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
  178. archive.writestr("summary.json", json.dumps(self._snapshot(job), ensure_ascii=False))
  179. for item in job["items"]:
  180. if item["status"] == "completed":
  181. for path in sorted((directory / "items" / item["id"]).rglob("*")):
  182. if path.is_file():
  183. archive.write(path, path.relative_to(directory).as_posix())
  184. temporary.replace(directory / "results.zip")
  185. except Exception:
  186. logger.exception("批量结果打包失败:%s", job_id)
  187. temporary.unlink(missing_ok=True)
  188. job.update(status="failed", error={"code": "archive_failed", "message": "结果打包失败,可按图片读取成功结果"})
  189. with self._lock:
  190. self._jobs[job_id] = job
  191. self._save(job)
  192. def _snapshot(self, job: dict) -> dict:
  193. result = deepcopy(job)
  194. counts = {state: sum(item["status"] == state for item in job["items"])
  195. for state in ("queued", "running", "completed", "failed", "interrupted")}
  196. result["progress"] = {"total": len(job["items"]), **counts,
  197. "finished": counts["completed"] + counts["failed"] + counts["interrupted"]}
  198. for item in result["items"]:
  199. item.pop("suffix", None)
  200. if item["status"] == "running":
  201. try:
  202. item["stage"] = json.loads((self.root / job["id"] / (item["id"] + ".stage.json")).read_text())["stage"]
  203. except (FileNotFoundError, ValueError, KeyError):
  204. pass
  205. return result
  206. def _get(self, job_id: str) -> dict:
  207. self.cleanup()
  208. job = self._jobs.get(job_id)
  209. if job is None:
  210. raise AlgorithmError("batch_not_found", "批量任务不存在或已过期", 404)
  211. return job
  212. def status(self, job_id: str) -> dict:
  213. with self._lock:
  214. return self._snapshot(self._get(job_id))
  215. def result(self, job_id: str, item_id: str) -> dict:
  216. with self._lock:
  217. job = self._get(job_id)
  218. item = next((item for item in job["items"] if item["id"] == item_id), None)
  219. if item is None:
  220. raise AlgorithmError("item_not_found", "图片任务不存在", 404)
  221. if item["status"] != "completed":
  222. raise AlgorithmError("result_unavailable", "该图片尚未成功完成,请查询任务状态", 409)
  223. return json.loads((self.root / job_id / "items" / item_id / "result.json").read_text())
  224. def open_archive(self, job_id: str) -> BinaryIO:
  225. with self._lock:
  226. job = self._get(job_id)
  227. if job["status"] not in TERMINAL:
  228. raise AlgorithmError("batch_not_finished", "批量任务尚未结束", 409)
  229. path = self.root / job_id / "results.zip"
  230. if not path.is_file():
  231. raise AlgorithmError("archive_unavailable", "结果包不可用,请按图片读取结果", 409)
  232. # 在清理锁内打开;POSIX 上过期删除也不影响已开始的下载。
  233. return path.open("rb")
  234. def close(self) -> None:
  235. with self._lock:
  236. self._closed = True
  237. self._stop.set()
  238. self._cleaner.join()
  239. self._pool.shutdown(wait=True, cancel_futures=True)
  240. for job_id, job in list(self._jobs.items()):
  241. if job["status"] not in TERMINAL:
  242. for item in job["items"]:
  243. if item["status"] not in ITEM_TERMINAL:
  244. self._interrupt(item)
  245. shutil.rmtree(self.root / job_id / "inputs", ignore_errors=True)
  246. self._finalize(job_id, interrupted=True)