|
20 | 20 | from pydantic import BaseModel |
21 | 21 |
|
22 | 22 | from . import conversion |
| 23 | +from geolibre_server.vector_ops import MAX_FEATURES as MAX_LAYER_FEATURES |
23 | 24 | from .runtime import ( |
24 | 25 | RUNTIME_CATALOG_TIMEOUT_SECS, |
25 | 26 | RUNTIME_DISCOVERY_TIMEOUT_SECS, |
@@ -88,6 +89,10 @@ class WhiteboxRunRequest(BaseModel): |
88 | 89 | _JOBS_LOCK = threading.Lock() |
89 | 90 | _RUNTIME_SETUP_LOCK = threading.Lock() |
90 | 91 | MAX_RETAINED_JOBS = 100 |
| 92 | +# Concurrent pending/running Whitebox jobs. Finished jobs are retained up to |
| 93 | +# MAX_RETAINED_JOBS; in-flight work is refused with HTTP 429 once this cap is |
| 94 | +# hit so a burst of /run calls cannot spawn unbounded tool sessions. |
| 95 | +MAX_IN_FLIGHT_JOBS = 8 |
91 | 96 |
|
92 | 97 |
|
93 | 98 | def _check_python_import(python_executable: str) -> None: |
@@ -701,10 +706,19 @@ def _write_layer_input(param_name: str, layer: dict[str, Any], temp_paths: list[ |
701 | 706 |
|
702 | 707 | Returns: |
703 | 708 | Path to the materialized input file. |
| 709 | +
|
| 710 | + Raises: |
| 711 | + ValueError: When the layer payload is not GeoJSON, or exceeds the |
| 712 | + shared feature cap used by vector/PostGIS/Sedona paths. |
704 | 713 | """ |
705 | 714 | geojson = layer.get("geojson") |
706 | 715 | if not isinstance(geojson, dict): |
707 | 716 | raise ValueError(f"Layer input for {param_name} does not contain GeoJSON.") |
| 717 | + features = geojson.get("features") or [] |
| 718 | + if isinstance(features, list) and len(features) > MAX_LAYER_FEATURES: |
| 719 | + raise ValueError( |
| 720 | + f"Layer input for {param_name} exceeds the {MAX_LAYER_FEATURES}-feature limit" |
| 721 | + ) |
708 | 722 | folder = Path(tempfile.mkdtemp(prefix="geolibre-whitebox-input-")) |
709 | 723 | temp_paths.append(folder) |
710 | 724 | path = folder / f"{_safe_output_stem('input', param_name)}.geojson" |
@@ -1041,15 +1055,41 @@ def _evict_finished_jobs_locked() -> None: |
1041 | 1055 | _JOBS.pop(job_id, None) |
1042 | 1056 |
|
1043 | 1057 |
|
| 1058 | +def _count_in_flight_jobs_locked() -> int: |
| 1059 | + """Return how many jobs are pending or running. Caller must hold ``_JOBS_LOCK``.""" |
| 1060 | + return sum(1 for job in _JOBS.values() if job.status in {"pending", "running"}) |
| 1061 | + |
| 1062 | + |
1044 | 1063 | @router.post("/run") |
1045 | 1064 | def whitebox_run(request: WhiteboxRunRequest): |
1046 | 1065 | """Start a background Whitebox tool run.""" |
1047 | 1066 | tool_id = request.tool_id.strip() |
1048 | 1067 | if not tool_id: |
1049 | 1068 | raise HTTPException(status_code=400, detail="tool_id is required") |
| 1069 | + # Reject oversized embedded layers before enqueueing work, matching the |
| 1070 | + # 413 vector/PostGIS/Sedona feature cap (defense-in-depth also lives in |
| 1071 | + # ``_write_layer_input``). |
| 1072 | + for name, layer in request.layer_inputs.items(): |
| 1073 | + geojson = layer.get("geojson") if isinstance(layer, dict) else None |
| 1074 | + if not isinstance(geojson, dict): |
| 1075 | + continue |
| 1076 | + features = geojson.get("features") or [] |
| 1077 | + if isinstance(features, list) and len(features) > MAX_LAYER_FEATURES: |
| 1078 | + raise HTTPException( |
| 1079 | + status_code=413, |
| 1080 | + detail=( |
| 1081 | + f"Layer input for {name} exceeds the " |
| 1082 | + f"{MAX_LAYER_FEATURES}-feature limit" |
| 1083 | + ), |
| 1084 | + ) |
1050 | 1085 | job_id = str(uuid.uuid4()) |
1051 | 1086 | now = _utc_now() |
1052 | 1087 | with _JOBS_LOCK: |
| 1088 | + if _count_in_flight_jobs_locked() >= MAX_IN_FLIGHT_JOBS: |
| 1089 | + raise HTTPException( |
| 1090 | + status_code=429, |
| 1091 | + detail="Too many Whitebox jobs in progress; try again shortly.", |
| 1092 | + ) |
1053 | 1093 | _JOBS[job_id] = JobState( |
1054 | 1094 | id=job_id, |
1055 | 1095 | status="pending", |
|
0 commit comments