| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- """批量真实计算、并行上限、失败隔离、恢复和保留期限。"""
- from concurrent.futures import ThreadPoolExecutor
- from datetime import datetime, timedelta, timezone
- import io
- import json
- from pathlib import Path
- import threading
- import time
- import zipfile
- import cv2
- import numpy as np
- import pytest
- from fastapi import FastAPI
- from fastapi.testclient import TestClient
- import config
- from api.algorithm_routes import router
- from algorithms.characteristic_curve_recognition import service
- from algorithms.characteristic_curve_recognition.batch import BatchManager
- BASE = "/api/algorithms/characteristic-curve-recognition"
- @pytest.fixture
- def client(tmp_path, monkeypatch):
- monkeypatch.setattr(config, "ALGORITHM_JOB_DIR", str(tmp_path / "jobs"))
- monkeypatch.setattr(config, "ALGORITHM_MAX_PARALLEL", 2)
- monkeypatch.setattr(config, "ALGORITHM_MAX_PENDING_IMAGES", 5)
- monkeypatch.setattr(service, "_slots", threading.BoundedSemaphore(2))
- app = FastAPI()
- app.include_router(router, prefix="/api")
- with TestClient(app) as client:
- yield client
- @pytest.fixture
- def chart():
- image = np.full((320, 420, 3), 255, np.uint8)
- cv2.line(image, (45, 25), (45, 275), (0, 0, 0), 2)
- cv2.line(image, (45, 275), (390, 275), (0, 0, 0), 2)
- for offset in (0, 30):
- points = np.array([[x, int(45 + offset + .0012 * (x - 50) ** 2)] for x in range(60, 380)], np.int32)
- cv2.polylines(image, [points], False, (0, 0, 0), 2)
- return cv2.imencode(".png", image)[1].tobytes()
- def wait_until(fn, timeout=15):
- deadline = time.monotonic() + timeout
- while time.monotonic() < deadline:
- result = fn()
- if result:
- return result
- time.sleep(.02)
- raise AssertionError("等待任务状态超时")
- def finished(client, url):
- def check():
- response = client.get(url)
- assert response.status_code == 200
- job = response.json()
- return job if job["status"] in {"completed", "partial_failed", "failed", "interrupted"} else None
- return wait_until(check, timeout=45)
- def test_real_parallel_batch_partial_failure_and_download(client, chart, monkeypatch):
- run = service.subprocess.run
- barrier = threading.Barrier(2)
- lock = threading.Lock()
- active = peak = 0
- def measured_run(*args, **kwargs):
- nonlocal active, peak
- with lock:
- active += 1
- peak = max(peak, active)
- try:
- barrier.wait(timeout=10)
- return run(*args, **kwargs)
- finally:
- with lock:
- active -= 1
- monkeypatch.setattr(service.subprocess, "run", measured_run)
- image = cv2.imdecode(np.frombuffer(chart, np.uint8), cv2.IMREAD_COLOR)
- jpeg = cv2.imencode(".jpg", image)[1].tobytes()
- response = client.post(BASE + "/batches", files=[
- ("files", ("../../同名.png", chart)), ("files", ("同名.png", jpeg)), ("files", ("坏图.png", b"bad"))])
- assert response.status_code == 202, response.text
- job = finished(client, response.headers["location"])
- assert peak == 2 # 真正进入了两个并行子进程调用。
- assert job["status"] == "partial_failed"
- assert job["progress"] == {"total": 3, "queued": 0, "running": 0, "completed": 2,
- "failed": 1, "interrupted": 0, "finished": 3}
- assert [item["filename"] for item in job["items"]] == ["同名.png", "同名.png", "坏图.png"]
- assert job["items"][2]["error"]["code"] == "invalid_image"
- for item in job["items"][:2]:
- result = client.get(item["result_url"])
- assert result.status_code == 200
- assert len(result.json()["model"]["curves"]) == 2
- assert result.json()["geometry"]["grid"]["cells"]
- response = client.get(job["download_url"])
- assert response.status_code == 200
- with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
- assert json.loads(archive.read("summary.json"))["status"] == "partial_failed"
- assert "items/0001/index.html" in archive.namelist()
- assert "items/0002/result.json" in archive.namelist()
- assert not any(name.startswith("items/0003") for name in archive.namelist())
- root = Path(config.ALGORITHM_JOB_DIR) / job["id"]
- assert not list((root / "inputs").iterdir())
- def test_queue_limits_shared_slots_and_progress(client, chart, monkeypatch):
- release = threading.Event()
- started = threading.Barrier(3)
- run_count = 0
- lock = threading.Lock()
- def blocked_run(command, **kwargs):
- nonlocal run_count
- with lock:
- run_count += 1
- first_pair = run_count <= 2
- stage_path = Path(command[-1])
- stage_path.write_text('{"stage":"piecewise_c1_fit"}')
- if first_pair:
- started.wait(timeout=5)
- assert release.wait(10)
- # 通过真实 service 的闸与超时转换,模拟快速成功产物。
- root = Path(command[5])
- (root / "diagnostics").mkdir(parents=True)
- for path in (root / "final_model.json", root / "diagnostics/geometry.json", root / "diagnostics/timing.json"):
- path.write_text("{}")
- return type("Result", (), {"returncode": 0})()
- monkeypatch.setattr(service.subprocess, "run", blocked_run)
- try:
- first = client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 3)
- assert first.status_code == 202
- started.wait(timeout=5)
- second = client.post(BASE + "/batches", files=[("files", ("y.png", chart))] * 2)
- assert second.status_code == 202
- excess = client.post(BASE + "/batches", files=[("files", ("z.png", chart))])
- assert excess.status_code == 429
- assert excess.json()["detail"]["code"] == "algorithm_queue_full"
- assert excess.headers["retry-after"] == "5"
- single = client.post(BASE, files={"file": ("single.png", chart)})
- assert single.status_code == 429
- job = client.get(first.headers["location"]).json()
- assert job["progress"]["running"] == 2
- assert job["progress"]["queued"] == 1
- assert job["items"][0]["stage"] == "piecewise_c1_fit"
- assert client.get(job["download_url"]).status_code == 409
- assert client.get(job["status_url"] + "/items/0001/result").status_code == 409
- assert client.get("/health-does-not-exist").status_code == 404 # HTTP 仍可响应。
- finally:
- release.set()
- assert finished(client, first.headers["location"])["status"] == "completed"
- assert finished(client, second.headers["location"])["status"] == "completed"
- def test_timeout_isolated_and_queue_reusable(client, chart, monkeypatch):
- def timeout(*args, **kwargs):
- raise service.subprocess.TimeoutExpired(args[0], kwargs["timeout"])
- monkeypatch.setattr(service.subprocess, "run", timeout)
- for _ in range(2):
- response = client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 2)
- assert response.status_code == 202
- job = finished(client, response.headers["location"])
- assert job["status"] == "failed"
- assert all(item["error"]["code"] == "algorithm_timeout" for item in job["items"])
- assert client.get(job["download_url"]).status_code == 200 # 失败清单仍可下载。
- def test_upload_validation_and_missing_jobs(client, chart, monkeypatch):
- assert client.post(BASE + "/batches").status_code == 422
- assert client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 21).status_code == 422
- monkeypatch.setattr("api.algorithm_routes.MAX_UPLOAD_BYTES", 8)
- assert client.post(BASE + "/batches", files=[("files", ("x.png", chart))]).status_code == 413
- monkeypatch.setattr("api.algorithm_routes.MAX_UPLOAD_BYTES", len(chart))
- monkeypatch.setattr("api.algorithm_routes.MAX_BATCH_BYTES", len(chart))
- assert client.post(BASE + "/batches", files=[("files", ("x.png", chart))] * 2).status_code == 413
- assert client.get(BASE + "/batches/missing").status_code == 404
- assert client.get(BASE + "/batches/missing/download").status_code == 404
- assert not list(Path(config.ALGORITHM_JOB_DIR).iterdir())
- def test_restore_interrupts_incomplete_and_retains_success(tmp_path):
- job_id = "a" * 32
- directory = tmp_path / job_id
- result_dir = directory / "items/0001"
- result_dir.mkdir(parents=True)
- (result_dir / "result.json").write_text('{"ok":true}')
- (directory / "inputs").mkdir()
- (directory / "inputs/0002.png").write_bytes(b"unfinished")
- job = {"id": job_id, "status": "running", "detailed": False, "items": [
- {"id": "0001", "status": "completed", "filename": "a.png"},
- {"id": "0002", "status": "running", "filename": "b.png"},
- {"id": "0003", "status": "queued", "filename": "c.png"}]}
- (directory / "job.json").write_text(json.dumps(job))
- manager = BatchManager(tmp_path)
- try:
- recovered = manager.status(job_id)
- assert recovered["status"] == "interrupted"
- assert recovered["progress"]["interrupted"] == 2
- assert manager.result(job_id, "0001") == {"ok": True}
- with manager.open_archive(job_id) as archive:
- assert zipfile.is_zipfile(archive)
- assert not (directory / "inputs").exists()
- finally:
- manager.close()
- manager = BatchManager(tmp_path)
- try:
- assert manager.status(job_id)["status"] == "interrupted"
- # 过期后删除持久化结果,查询返回 404。
- manager._jobs[job_id]["expires_at"] = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
- manager.cleanup()
- assert not directory.exists()
- with pytest.raises(service.AlgorithmError) as exc:
- manager.status(job_id)
- assert exc.value.status_code == 404
- finally:
- manager.close()
- def test_shutdown_finishes_active_and_interrupts_queued(tmp_path, chart, monkeypatch):
- entered, release = threading.Event(), threading.Event()
- def execute(image_path, result_dir, **kwargs):
- entered.set()
- assert release.wait(10)
- result_dir.mkdir(parents=True)
- (result_dir / "result.json").write_text("{}")
- monkeypatch.setattr(service, "execute_image", execute)
- manager = BatchManager(tmp_path, parallelism=1)
- job = manager.submit([("a.png", chart), ("b.png", chart)])
- assert entered.wait(5)
- with ThreadPoolExecutor(1) as pool:
- closing = pool.submit(manager.close)
- try:
- wait_until(lambda: manager._closed)
- finally:
- release.set()
- closing.result(timeout=5)
- saved = json.loads((tmp_path / job["id"] / "job.json").read_text())
- assert saved["status"] == "interrupted"
- assert [item["status"] for item in saved["items"]] == ["completed", "interrupted"]
|