test_algorithm_batches.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """批量真实计算、并行上限、失败隔离、恢复和保留期限。"""
  2. from concurrent.futures import ThreadPoolExecutor
  3. from datetime import datetime, timedelta, timezone
  4. import io
  5. import json
  6. from pathlib import Path
  7. import threading
  8. import time
  9. import zipfile
  10. import cv2
  11. import numpy as np
  12. import pytest
  13. from fastapi import FastAPI
  14. from fastapi.testclient import TestClient
  15. import config
  16. from api.algorithm_routes import router
  17. from algorithms.characteristic_curve_recognition import service
  18. from algorithms.characteristic_curve_recognition.batch import BatchManager
  19. BASE = "/api/algorithms/characteristic-curve-recognition"
  20. @pytest.fixture
  21. def client(tmp_path, monkeypatch):
  22. monkeypatch.setattr(config, "ALGORITHM_JOB_DIR", str(tmp_path / "jobs"))
  23. monkeypatch.setattr(config, "ALGORITHM_MAX_PARALLEL", 2)
  24. monkeypatch.setattr(config, "ALGORITHM_MAX_PENDING_IMAGES", 5)
  25. monkeypatch.setattr(service, "_slots", threading.BoundedSemaphore(2))
  26. app = FastAPI()
  27. app.include_router(router, prefix="/api")
  28. with TestClient(app) as client:
  29. yield client
  30. @pytest.fixture
  31. def chart():
  32. image = np.full((320, 420, 3), 255, np.uint8)
  33. cv2.line(image, (45, 25), (45, 275), (0, 0, 0), 2)
  34. cv2.line(image, (45, 275), (390, 275), (0, 0, 0), 2)
  35. for offset in (0, 30):
  36. points = np.array([[x, int(45 + offset + .0012 * (x - 50) ** 2)] for x in range(60, 380)], np.int32)
  37. cv2.polylines(image, [points], False, (0, 0, 0), 2)
  38. return cv2.imencode(".png", image)[1].tobytes()
  39. def wait_until(fn, timeout=15):
  40. deadline = time.monotonic() + timeout
  41. while time.monotonic() < deadline:
  42. result = fn()
  43. if result:
  44. return result
  45. time.sleep(.02)
  46. raise AssertionError("等待任务状态超时")
  47. def finished(client, url):
  48. def check():
  49. response = client.get(url)
  50. assert response.status_code == 200
  51. job = response.json()
  52. return job if job["status"] in {"completed", "partial_failed", "failed", "interrupted"} else None
  53. return wait_until(check, timeout=45)
  54. def test_real_parallel_batch_partial_failure_and_download(client, chart, monkeypatch):
  55. run = service.subprocess.run
  56. barrier = threading.Barrier(2)
  57. lock = threading.Lock()
  58. active = peak = 0
  59. def measured_run(*args, **kwargs):
  60. nonlocal active, peak
  61. with lock:
  62. active += 1
  63. peak = max(peak, active)
  64. try:
  65. barrier.wait(timeout=10)
  66. return run(*args, **kwargs)
  67. finally:
  68. with lock:
  69. active -= 1
  70. monkeypatch.setattr(service.subprocess, "run", measured_run)
  71. image = cv2.imdecode(np.frombuffer(chart, np.uint8), cv2.IMREAD_COLOR)
  72. jpeg = cv2.imencode(".jpg", image)[1].tobytes()
  73. response = client.post(BASE + "/batches", files=[
  74. ("files", ("../../同名.png", chart)), ("files", ("同名.png", jpeg)), ("files", ("坏图.png", b"bad"))])
  75. assert response.status_code == 202, response.text
  76. job = finished(client, response.headers["location"])
  77. assert peak == 2 # 真正进入了两个并行子进程调用。
  78. assert job["status"] == "partial_failed"
  79. assert job["progress"] == {"total": 3, "queued": 0, "running": 0, "completed": 2,
  80. "failed": 1, "interrupted": 0, "finished": 3}
  81. assert [item["filename"] for item in job["items"]] == ["同名.png", "同名.png", "坏图.png"]
  82. assert job["items"][2]["error"]["code"] == "invalid_image"
  83. for item in job["items"][:2]:
  84. result = client.get(item["result_url"])
  85. assert result.status_code == 200
  86. assert len(result.json()["model"]["curves"]) == 2
  87. assert result.json()["geometry"]["grid"]["cells"]
  88. response = client.get(job["download_url"])
  89. assert response.status_code == 200
  90. with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
  91. assert json.loads(archive.read("summary.json"))["status"] == "partial_failed"
  92. assert "items/0001/index.html" in archive.namelist()
  93. assert "items/0002/result.json" in archive.namelist()
  94. assert not any(name.startswith("items/0003") for name in archive.namelist())
  95. root = Path(config.ALGORITHM_JOB_DIR) / job["id"]
  96. assert not list((root / "inputs").iterdir())
  97. def test_queue_limits_shared_slots_and_progress(client, chart, monkeypatch):
  98. release = threading.Event()
  99. started = threading.Barrier(3)
  100. run_count = 0
  101. lock = threading.Lock()
  102. def blocked_run(command, **kwargs):
  103. nonlocal run_count
  104. with lock:
  105. run_count += 1
  106. first_pair = run_count <= 2
  107. stage_path = Path(command[-1])
  108. stage_path.write_text('{"stage":"piecewise_c1_fit"}')
  109. if first_pair:
  110. started.wait(timeout=5)
  111. assert release.wait(10)
  112. # 通过真实 service 的闸与超时转换,模拟快速成功产物。
  113. root = Path(command[5])
  114. (root / "diagnostics").mkdir(parents=True)
  115. for path in (root / "final_model.json", root / "diagnostics/geometry.json", root / "diagnostics/timing.json"):
  116. path.write_text("{}")
  117. return type("Result", (), {"returncode": 0})()
  118. monkeypatch.setattr(service.subprocess, "run", blocked_run)
  119. try:
  120. first = client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 3)
  121. assert first.status_code == 202
  122. started.wait(timeout=5)
  123. second = client.post(BASE + "/batches", files=[("files", ("y.png", chart))] * 2)
  124. assert second.status_code == 202
  125. excess = client.post(BASE + "/batches", files=[("files", ("z.png", chart))])
  126. assert excess.status_code == 429
  127. assert excess.json()["detail"]["code"] == "algorithm_queue_full"
  128. assert excess.headers["retry-after"] == "5"
  129. single = client.post(BASE, files={"file": ("single.png", chart)})
  130. assert single.status_code == 429
  131. job = client.get(first.headers["location"]).json()
  132. assert job["progress"]["running"] == 2
  133. assert job["progress"]["queued"] == 1
  134. assert job["items"][0]["stage"] == "piecewise_c1_fit"
  135. assert client.get(job["download_url"]).status_code == 409
  136. assert client.get(job["status_url"] + "/items/0001/result").status_code == 409
  137. assert client.get("/health-does-not-exist").status_code == 404 # HTTP 仍可响应。
  138. finally:
  139. release.set()
  140. assert finished(client, first.headers["location"])["status"] == "completed"
  141. assert finished(client, second.headers["location"])["status"] == "completed"
  142. def test_timeout_isolated_and_queue_reusable(client, chart, monkeypatch):
  143. def timeout(*args, **kwargs):
  144. raise service.subprocess.TimeoutExpired(args[0], kwargs["timeout"])
  145. monkeypatch.setattr(service.subprocess, "run", timeout)
  146. for _ in range(2):
  147. response = client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 2)
  148. assert response.status_code == 202
  149. job = finished(client, response.headers["location"])
  150. assert job["status"] == "failed"
  151. assert all(item["error"]["code"] == "algorithm_timeout" for item in job["items"])
  152. assert client.get(job["download_url"]).status_code == 200 # 失败清单仍可下载。
  153. def test_upload_validation_and_missing_jobs(client, chart, monkeypatch):
  154. assert client.post(BASE + "/batches").status_code == 422
  155. assert client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 21).status_code == 422
  156. monkeypatch.setattr("api.algorithm_routes.MAX_UPLOAD_BYTES", 8)
  157. assert client.post(BASE + "/batches", files=[("files", ("x.png", chart))]).status_code == 413
  158. monkeypatch.setattr("api.algorithm_routes.MAX_UPLOAD_BYTES", len(chart))
  159. monkeypatch.setattr("api.algorithm_routes.MAX_BATCH_BYTES", len(chart))
  160. assert client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 2).status_code == 413
  161. assert client.get(BASE + "/batches/missing").status_code == 404
  162. assert client.get(BASE + "/batches/missing/download").status_code == 404
  163. assert not list(Path(config.ALGORITHM_JOB_DIR).iterdir())
  164. def test_restore_interrupts_incomplete_and_retains_success(tmp_path):
  165. job_id = "a" * 32
  166. directory = tmp_path / job_id
  167. result_dir = directory / "items/0001"
  168. result_dir.mkdir(parents=True)
  169. (result_dir / "result.json").write_text('{"ok":true}')
  170. (directory / "inputs").mkdir()
  171. (directory / "inputs/0002.png").write_bytes(b"unfinished")
  172. job = {"id": job_id, "status": "running", "detailed": False, "items": [
  173. {"id": "0001", "status": "completed", "filename": "a.png"},
  174. {"id": "0002", "status": "running", "filename": "b.png"},
  175. {"id": "0003", "status": "queued", "filename": "c.png"}]}
  176. (directory / "job.json").write_text(json.dumps(job))
  177. manager = BatchManager(tmp_path)
  178. try:
  179. recovered = manager.status(job_id)
  180. assert recovered["status"] == "interrupted"
  181. assert recovered["progress"]["interrupted"] == 2
  182. assert manager.result(job_id, "0001") == {"ok": True}
  183. with manager.open_archive(job_id) as archive:
  184. assert zipfile.is_zipfile(archive)
  185. assert not (directory / "inputs").exists()
  186. finally:
  187. manager.close()
  188. manager = BatchManager(tmp_path)
  189. try:
  190. assert manager.status(job_id)["status"] == "interrupted"
  191. # 过期后删除持久化结果,查询返回 404。
  192. manager._jobs[job_id]["expires_at"] = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
  193. manager.cleanup()
  194. assert not directory.exists()
  195. with pytest.raises(service.AlgorithmError) as exc:
  196. manager.status(job_id)
  197. assert exc.value.status_code == 404
  198. finally:
  199. manager.close()
  200. def test_shutdown_finishes_active_and_interrupts_queued(tmp_path, chart, monkeypatch):
  201. entered, release = threading.Event(), threading.Event()
  202. def execute(image_path, result_dir, **kwargs):
  203. entered.set()
  204. assert release.wait(10)
  205. result_dir.mkdir(parents=True)
  206. (result_dir / "result.json").write_text("{}")
  207. monkeypatch.setattr(service, "execute_image", execute)
  208. manager = BatchManager(tmp_path, parallelism=1)
  209. job = manager.submit([("a.png", chart), ("b.png", chart)])
  210. assert entered.wait(5)
  211. with ThreadPoolExecutor(1) as pool:
  212. closing = pool.submit(manager.close)
  213. try:
  214. wait_until(lambda: manager._closed)
  215. finally:
  216. release.set()
  217. closing.result(timeout=5)
  218. saved = json.loads((tmp_path / job["id"] / "job.json").read_text())
  219. assert saved["status"] == "interrupted"
  220. assert [item["status"] for item in saved["items"]] == ["completed", "interrupted"]