Coverage for gco/services/api_routes/queue.py: 87.01%
145 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""DynamoDB-backed global job queue endpoints."""
3from __future__ import annotations
5import hashlib
6import json
7import logging
8import os
9import re
10import uuid
11from copy import deepcopy
12from datetime import UTC, datetime
13from typing import TYPE_CHECKING, Any
15from fastapi import APIRouter, Header, HTTPException, Query
16from fastapi.responses import JSONResponse, Response
18from gco.services.api_shared import QueuedJobRequest, _check_processor
19from gco.services.central_queue_worker import process_queued_jobs_once
20from gco.services.structured_logging import sanitize_log_value
21from gco.services.template_store import JobSubmissionConflict
23if TYPE_CHECKING:
24 from gco.services.template_store import JobStore
26router = APIRouter(prefix="/api/v1/queue", tags=["Job Queue"])
27logger = logging.getLogger(__name__)
29_AWS_REGION_PATTERN = re.compile(r"^[a-z]{2,4}(?:-[a-z0-9]+)+-[0-9]+$")
30_DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$")
31_IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9._~:/+=-]{1,128}$")
32_IDEMPOTENCY_NAMESPACE = uuid.UUID("88284d12-1e04-47d5-8871-607a9e4dac09")
35def _validated_queue_manifest(request: QueuedJobRequest) -> dict[str, Any]:
36 """Validate the queue envelope and return an isolated Job manifest."""
37 configured_regions = {
38 value.strip() for value in os.getenv("QUEUE_TARGET_REGIONS", "").split(",") if value.strip()
39 }
40 if configured_regions: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 if request.target_region not in configured_regions:
42 raise HTTPException(status_code=422, detail="target_region is not deployed")
43 elif not _AWS_REGION_PATTERN.fullmatch(request.target_region): 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 raise HTTPException(status_code=422, detail="target_region is not a valid AWS region")
46 processor = _check_processor()
47 if request.namespace not in processor.allowed_namespaces: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 raise HTTPException(status_code=422, detail="namespace is not allowed")
49 if len(request.namespace) > 63 or not _DNS_LABEL_PATTERN.fullmatch(request.namespace): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 raise HTTPException(status_code=422, detail="namespace is not a valid Kubernetes name")
52 manifest = deepcopy(request.manifest)
53 if manifest.get("apiVersion") != "batch/v1" or manifest.get("kind") != "Job": 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 raise HTTPException(
55 status_code=422,
56 detail="central queue accepts only apiVersion 'batch/v1', kind 'Job'",
57 )
58 metadata = manifest.get("metadata")
59 if not isinstance(metadata, dict): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 raise HTTPException(status_code=422, detail="manifest.metadata must be an object")
61 name = metadata.get("name")
62 if not isinstance(name, str) or len(name) > 63 or not _DNS_LABEL_PATTERN.fullmatch(name): 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
63 raise HTTPException(status_code=422, detail="manifest.metadata.name is invalid")
64 declared_namespace = metadata.get("namespace")
65 if declared_namespace is not None and declared_namespace != request.namespace: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 raise HTTPException(
67 status_code=422,
68 detail="manifest namespace must match the queue envelope namespace",
69 )
70 metadata["namespace"] = request.namespace
71 return manifest
74def _validated_spot_gate(request: QueuedJobRequest) -> tuple[str, str] | None:
75 """Validate the optional spot price gate pair on a queue submission."""
76 from gco.services.spot_price_gate import validate_spot_gate_fields
78 error = validate_spot_gate_fields(request.max_spot_price, request.spot_instance_type)
79 if error:
80 raise HTTPException(status_code=422, detail=error)
81 if request.max_spot_price is None or request.spot_instance_type is None:
82 return None
83 # Serialize the cap as a plain decimal string — DynamoDB items must not
84 # carry floats, and a stable rendering keeps idempotency hashes exact.
85 return (f"{request.max_spot_price:.6f}", request.spot_instance_type)
88def _submission_hash(request: QueuedJobRequest, manifest: dict[str, Any]) -> str:
89 payload = {
90 "manifest": manifest,
91 "target_region": request.target_region,
92 "namespace": request.namespace,
93 "priority": request.priority,
94 "labels": request.labels or {},
95 }
96 # Only price-capped submissions carry the gate fields, so historical
97 # idempotency keys hash identically to pre-gate deployments.
98 if request.max_spot_price is not None or request.spot_instance_type is not None:
99 payload["max_spot_price"] = request.max_spot_price
100 payload["spot_instance_type"] = request.spot_instance_type
101 canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
102 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
105def _get_job_store() -> JobStore:
106 from gco.services.manifest_api import job_store
108 if job_store is None:
109 raise HTTPException(status_code=503, detail="Job store not initialized")
110 return job_store
113@router.post("/jobs")
114async def submit_job_to_queue(
115 request: QueuedJobRequest,
116 idempotency_key: str | None = Header(
117 None,
118 alias="Idempotency-Key",
119 description="Stable key for safely replaying an identical submission",
120 ),
121) -> Response:
122 """Submit one validated ``batch/v1`` Job exactly once."""
123 if idempotency_key is not None and not _IDEMPOTENCY_KEY_PATTERN.fullmatch(idempotency_key): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 raise HTTPException(status_code=422, detail="Idempotency-Key is invalid")
126 manifest = _validated_queue_manifest(request)
127 spot_gate = _validated_spot_gate(request)
128 request_hash = _submission_hash(request, manifest)
129 job_id = (
130 str(uuid.uuid5(_IDEMPOTENCY_NAMESPACE, idempotency_key))
131 if idempotency_key
132 else str(uuid.uuid4())
133 )
134 store = _get_job_store()
136 try:
137 job = store.submit_job(
138 job_id=job_id,
139 manifest=manifest,
140 target_region=request.target_region,
141 namespace=request.namespace,
142 priority=request.priority,
143 labels=request.labels,
144 idempotency_key=idempotency_key,
145 request_hash=request_hash,
146 spot_max_price=spot_gate[0] if spot_gate else None,
147 spot_instance_type=spot_gate[1] if spot_gate else None,
148 )
149 except JobSubmissionConflict as error:
150 raise HTTPException(status_code=409, detail=str(error)) from error
151 except Exception as error:
152 logger.exception("Failed to queue job")
153 raise HTTPException(status_code=500, detail="Failed to queue job") from error
155 replay = bool(job.pop("idempotent_replay", False))
156 return JSONResponse(
157 status_code=200 if replay else 201,
158 content={
159 "timestamp": datetime.now(UTC).isoformat(),
160 "message": "Idempotent job replay" if replay else "Job queued successfully",
161 "job": job,
162 },
163 )
166@router.get("/jobs")
167async def list_queued_jobs(
168 target_region: str | None = Query(None, description="Filter by target region"),
169 status: str | None = Query(None, description="Filter by status"),
170 namespace: str | None = Query(None, description="Filter by namespace"),
171 limit: int = Query(100, description="Maximum results", ge=1, le=1000),
172 cursor: str | None = Query(
173 None,
174 description="Opaque continuation cursor returned by the previous page",
175 max_length=2048,
176 ),
177) -> Response:
178 """List one bounded page of jobs with optional filters."""
179 store = _get_job_store()
180 try:
181 jobs, next_cursor, partial = store.list_jobs_page(
182 target_region=target_region,
183 status=status,
184 namespace=namespace,
185 limit=limit,
186 cursor=cursor,
187 )
188 return JSONResponse(
189 status_code=200,
190 content={
191 "timestamp": datetime.now(UTC).isoformat(),
192 "count": len(jobs),
193 "jobs": jobs,
194 "next_cursor": next_cursor,
195 "partial": partial,
196 },
197 )
198 except ValueError as error:
199 raise HTTPException(status_code=422, detail=str(error)) from error
200 except Exception as e:
201 logger.error(f"Failed to list queued jobs: {e}")
202 raise HTTPException(status_code=500, detail=f"Failed to list jobs: {e!s}") from e
205@router.get("/jobs/{job_id}")
206async def get_queued_job(job_id: str) -> Response:
207 """Get details of a specific queued job."""
208 store = _get_job_store()
209 try:
210 job = store.get_job(job_id)
211 if job is None:
212 raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
213 return JSONResponse(
214 status_code=200,
215 content={"timestamp": datetime.now(UTC).isoformat(), "job": job},
216 )
217 except HTTPException:
218 raise
219 except Exception as e:
220 logger.error("Failed to get job %s: %s", sanitize_log_value(job_id), e)
221 raise HTTPException(status_code=500, detail=f"Failed to get job: {e!s}") from e
224@router.delete("/jobs/{job_id}")
225async def cancel_queued_job(
226 job_id: str, reason: str | None = Query(None, description="Cancellation reason")
227) -> Response:
228 """Cancel a job only while it remains unclaimed in the queue."""
229 store = _get_job_store()
230 try:
231 cancelled = store.cancel_job(job_id, reason=reason)
232 if not cancelled:
233 raise HTTPException(
234 status_code=409,
235 detail=f"Job '{job_id}' cannot be cancelled (already running or completed)",
236 )
237 return JSONResponse(
238 status_code=200,
239 content={
240 "timestamp": datetime.now(UTC).isoformat(),
241 "message": f"Job '{job_id}' cancelled successfully",
242 },
243 )
244 except HTTPException:
245 raise
246 except Exception as e:
247 logger.error("Failed to cancel job %s: %s", sanitize_log_value(job_id), e)
248 raise HTTPException(status_code=500, detail=f"Failed to cancel job: {e!s}") from e
251@router.get("/stats")
252async def get_queue_stats() -> Response:
253 """Get job queue statistics by region and status."""
254 store = _get_job_store()
255 try:
256 counts, records_evaluated, truncated = store.get_job_count_summary()
257 total_jobs = sum(sum(statuses.values()) for statuses in counts.values())
258 total_queued = sum(statuses.get("queued", 0) for statuses in counts.values())
259 total_running = sum(statuses.get("running", 0) for statuses in counts.values())
261 return JSONResponse(
262 status_code=200,
263 content={
264 "timestamp": datetime.now(UTC).isoformat(),
265 "summary": {
266 "total_jobs": total_jobs,
267 "total_queued": total_queued,
268 "total_running": total_running,
269 "complete": not truncated,
270 "records_evaluated": records_evaluated,
271 },
272 "by_region": counts,
273 },
274 )
275 except Exception as e:
276 logger.error(f"Failed to get queue stats: {e}")
277 raise HTTPException(status_code=500, detail=f"Failed to get stats: {e!s}") from e
280@router.post("/poll")
281async def poll_and_process_jobs(
282 limit: int = Query(5, description="Maximum jobs to process", ge=1, le=20),
283) -> Response:
284 """Run one immediate queue-worker pass for this region.
286 The manifest API also runs this same processor continuously when the
287 deployment enables ``CENTRAL_QUEUE_WORKER_ENABLED``. This endpoint remains
288 useful for an authenticated operator-triggered pass and diagnostics.
289 """
290 processor = _check_processor()
291 store = _get_job_store()
293 try:
294 jobs_polled, processed_jobs = await process_queued_jobs_once(
295 processor,
296 store,
297 limit=limit,
298 )
299 return JSONResponse(
300 status_code=200,
301 content={
302 "timestamp": datetime.now(UTC).isoformat(),
303 "region": processor.region,
304 "jobs_polled": jobs_polled,
305 "jobs_processed": len(processed_jobs),
306 "results": processed_jobs,
307 },
308 )
309 except Exception as e:
310 logger.error(f"Failed to poll jobs: {e}")
311 raise HTTPException(status_code=500, detail=f"Failed to poll jobs: {e!s}") from e