|
30 | 30 | from auth import get_current_user |
31 | 31 | from middleware import HostCheckMiddleware |
32 | 32 | from job_manager import JobManager, JobStatus |
| 33 | +from queue_manager import QueueManager |
33 | 34 |
|
34 | 35 |
|
35 | 36 | # ============================================================================ |
|
48 | 49 | # Initialize job manager for async batch processing |
49 | 50 | job_manager = JobManager() |
50 | 51 |
|
| 52 | +# Initialize queue manager for async single-image processing |
| 53 | +verify_queue = QueueManager( |
| 54 | + db_path=Path(settings.queue_db_path), |
| 55 | + max_attempts=settings.queue_max_attempts, |
| 56 | +) |
| 57 | + |
51 | 58 |
|
52 | 59 | # ============================================================================ |
53 | 60 | # Pydantic Models |
@@ -154,6 +161,24 @@ class ErrorResponse(BaseModel): |
154 | 161 | correlation_id: str |
155 | 162 |
|
156 | 163 |
|
| 164 | +class AsyncVerifySubmitResponse(BaseModel): |
| 165 | + """Response from async single-image verify submission.""" |
| 166 | + job_id: str |
| 167 | + status: str # always 'pending' on submit |
| 168 | + message: str |
| 169 | + |
| 170 | + |
| 171 | +class AsyncVerifyStatusResponse(BaseModel): |
| 172 | + """Response from async single-image verify status poll.""" |
| 173 | + job_id: str |
| 174 | + status: str # pending | processing | completed | failed | cancelled |
| 175 | + attempts: int |
| 176 | + max_attempts: int |
| 177 | + result: Optional[VerifyResponse] = None |
| 178 | + error: Optional[str] = None |
| 179 | + queue_depth: Optional[int] = None # jobs ahead in queue (only when pending) |
| 180 | + |
| 181 | + |
157 | 182 | # ============================================================================ |
158 | 183 | # FastAPI Application |
159 | 184 | # ============================================================================ |
@@ -910,6 +935,131 @@ async def delete_batch_job( |
910 | 935 | return {"message": f"Job {job_id} deleted successfully"} |
911 | 936 |
|
912 | 937 |
|
| 938 | +# ============================================================================ |
| 939 | +# Async Single-Image Verify Endpoints (queue-based, CloudFront-safe) |
| 940 | +# ============================================================================ |
| 941 | + |
| 942 | +@app.post("/verify/async", response_model=AsyncVerifySubmitResponse) |
| 943 | +async def submit_async_verify( |
| 944 | + image: UploadFile = File(..., description="Label image file (max 10MB)"), |
| 945 | + ground_truth: Optional[str] = Form(None, description="Ground truth JSON string"), |
| 946 | + username: str = Depends(get_current_user) |
| 947 | +) -> AsyncVerifySubmitResponse: |
| 948 | + """ |
| 949 | + Submit a single label image for asynchronous verification via the worker queue. |
| 950 | +
|
| 951 | + Returns immediately with a ``job_id``. Poll ``GET /verify/status/{job_id}`` |
| 952 | + every 2–3 seconds until ``status`` is ``completed`` or ``failed``. |
| 953 | +
|
| 954 | + This endpoint is designed to work within CloudFront's 60-second origin |
| 955 | + read timeout: the HTTP response is sent instantly, and Ollama processing |
| 956 | + (~10s) happens in the separate worker container. |
| 957 | +
|
| 958 | + **Request:** |
| 959 | + - ``image``: Label image (JPEG or PNG, max 10MB) |
| 960 | + - ``ground_truth``: Optional JSON with expected values |
| 961 | +
|
| 962 | + **Response:** |
| 963 | + - ``job_id``: Use to poll ``GET /verify/status/{job_id}`` |
| 964 | + - ``status``: ``pending`` |
| 965 | +
|
| 966 | + **Example:** |
| 967 | + ```bash |
| 968 | + JOB=$(curl -s -X POST https://example.com/verify/async \\ |
| 969 | + -F "image=@label.jpg" | jq -r .job_id) |
| 970 | + # Poll until done: |
| 971 | + curl https://example.com/verify/status/$JOB |
| 972 | + ``` |
| 973 | + """ |
| 974 | + correlation_id = get_correlation_id() |
| 975 | + logger.info(f"[{correlation_id}] POST /verify/async - {image.filename}") |
| 976 | + |
| 977 | + # Validate image |
| 978 | + validate_image_file(image, correlation_id) |
| 979 | + |
| 980 | + # Parse optional ground truth |
| 981 | + ground_truth_data = parse_ground_truth(ground_truth, correlation_id) |
| 982 | + |
| 983 | + # Persist image to shared volume so the worker container can read it. |
| 984 | + # Each job gets its own subdirectory to avoid filename collisions. |
| 985 | + job_dir = Path(settings.queue_db_path).parent / "async" / str(uuid.uuid4()) |
| 986 | + job_dir.mkdir(parents=True, exist_ok=True) |
| 987 | + |
| 988 | + # Sanitise filename (keep extension only) |
| 989 | + suffix = Path(image.filename).suffix.lower() if image.filename else ".jpg" |
| 990 | + image_dest = job_dir / f"image{suffix}" |
| 991 | + await save_upload_file(image, image_dest) |
| 992 | + |
| 993 | + job_id = verify_queue.enqueue( |
| 994 | + image_path=str(image_dest), |
| 995 | + ground_truth=ground_truth_data, |
| 996 | + ) |
| 997 | + |
| 998 | + logger.info(f"[{correlation_id}] Enqueued async verify job {job_id}") |
| 999 | + |
| 1000 | + return AsyncVerifySubmitResponse( |
| 1001 | + job_id=job_id, |
| 1002 | + status="pending", |
| 1003 | + message=f"Job submitted. Poll GET /verify/status/{job_id} for results.", |
| 1004 | + ) |
| 1005 | + |
| 1006 | + |
| 1007 | +@app.get("/verify/status/{job_id}", response_model=AsyncVerifyStatusResponse) |
| 1008 | +async def get_async_verify_status( |
| 1009 | + job_id: str, |
| 1010 | + username: str = Depends(get_current_user) |
| 1011 | +) -> AsyncVerifyStatusResponse: |
| 1012 | + """ |
| 1013 | + Poll the status of a queued single-image verify job. |
| 1014 | +
|
| 1015 | + Call this endpoint every 2–3 seconds after submitting via |
| 1016 | + ``POST /verify/async``. |
| 1017 | +
|
| 1018 | + **Status values:** |
| 1019 | + - ``pending`` — job is waiting in the queue |
| 1020 | + - ``processing`` — worker is currently running Ollama inference |
| 1021 | + - ``completed`` — result is ready (see ``result`` field) |
| 1022 | + - ``failed`` — all retry attempts exhausted (see ``error`` field) |
| 1023 | + - ``cancelled`` — job was cancelled |
| 1024 | +
|
| 1025 | + **Example:** |
| 1026 | + ```bash |
| 1027 | + curl https://example.com/verify/status/abc123 |
| 1028 | + ``` |
| 1029 | + """ |
| 1030 | + correlation_id = get_correlation_id() |
| 1031 | + job = verify_queue.get(job_id) |
| 1032 | + |
| 1033 | + if job is None: |
| 1034 | + raise HTTPException( |
| 1035 | + status_code=status.HTTP_404_NOT_FOUND, |
| 1036 | + detail=f"Verify job {job_id} not found", |
| 1037 | + ) |
| 1038 | + |
| 1039 | + result_obj: Optional[VerifyResponse] = None |
| 1040 | + if job["status"] == "completed" and job.get("result"): |
| 1041 | + try: |
| 1042 | + result_obj = VerifyResponse(**job["result"]) |
| 1043 | + except Exception as exc: |
| 1044 | + logger.error( |
| 1045 | + f"[{correlation_id}] Failed to deserialise result for job {job_id}: {exc}" |
| 1046 | + ) |
| 1047 | + |
| 1048 | + queue_depth = None |
| 1049 | + if job["status"] == "pending": |
| 1050 | + queue_depth = verify_queue.queue_depth() |
| 1051 | + |
| 1052 | + return AsyncVerifyStatusResponse( |
| 1053 | + job_id=job_id, |
| 1054 | + status=job["status"], |
| 1055 | + attempts=job["attempts"], |
| 1056 | + max_attempts=job["max_attempts"], |
| 1057 | + result=result_obj, |
| 1058 | + error=job.get("error"), |
| 1059 | + queue_depth=queue_depth, |
| 1060 | + ) |
| 1061 | + |
| 1062 | + |
913 | 1063 | # ============================================================================ |
914 | 1064 | # Exception Handlers |
915 | 1065 | # ============================================================================ |
|
0 commit comments