fastprocesses 0.7.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastprocesses/__init__.py +8 -0
- fastprocesses/api/__init__.py +0 -0
- fastprocesses/api/manager.py +377 -0
- fastprocesses/api/router.py +177 -0
- fastprocesses/api/server.py +24 -0
- fastprocesses/celery_worker.py +45 -0
- fastprocesses/common.py +59 -0
- fastprocesses/core/__init__.py +0 -0
- fastprocesses/core/base_process.py +137 -0
- fastprocesses/core/cache.py +55 -0
- fastprocesses/core/config.py +17 -0
- fastprocesses/core/logging.py +75 -0
- fastprocesses/core/models.py +188 -0
- fastprocesses/processes/__init__.py +0 -0
- fastprocesses/processes/process_registry.py +114 -0
- fastprocesses/py.typed +0 -0
- fastprocesses/worker/__init__.py +0 -0
- fastprocesses/worker/celery_app.py +154 -0
- fastprocesses-0.7.4.dist-info/AUTHORS.md +1 -0
- fastprocesses-0.7.4.dist-info/METADATA +274 -0
- fastprocesses-0.7.4.dist-info/RECORD +23 -0
- fastprocesses-0.7.4.dist-info/WHEEL +4 -0
- fastprocesses-0.7.4.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
from celery.result import AsyncResult
|
|
7
|
+
|
|
8
|
+
from fastprocesses.common import redis_cache
|
|
9
|
+
from fastprocesses.core.logging import logger
|
|
10
|
+
from fastprocesses.core.models import (
|
|
11
|
+
CalculationTask,
|
|
12
|
+
ExecutionMode,
|
|
13
|
+
ProcessDescription,
|
|
14
|
+
ProcessExecRequestBody,
|
|
15
|
+
ProcessExecResponse,
|
|
16
|
+
)
|
|
17
|
+
from fastprocesses.processes.process_registry import get_process_registry
|
|
18
|
+
from fastprocesses.common import celery_app
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExecutionStrategy(ABC):
|
|
22
|
+
"""
|
|
23
|
+
Abstract base class implementing the Strategy pattern for process execution.
|
|
24
|
+
Different execution modes (sync/async) implement this interface.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, process_manager):
|
|
28
|
+
self.process_manager: ProcessManager = process_manager
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def execute(self, process_id: str, calculation_task: CalculationTask) -> ProcessExecResponse:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
class AsyncExecutionStrategy(ExecutionStrategy):
|
|
35
|
+
"""
|
|
36
|
+
Handles asynchronous process execution by:
|
|
37
|
+
1. Submitting task to Celery queue
|
|
38
|
+
2. Creating initial job status in cache
|
|
39
|
+
3. Returning immediately with job ID
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def execute(self, process_id: str, calculation_task: CalculationTask) -> ProcessExecResponse:
|
|
43
|
+
|
|
44
|
+
# dump data to json
|
|
45
|
+
serialized_data = json.dumps(calculation_task.model_dump(
|
|
46
|
+
include={"inputs", "outputs", "response"}
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
# Submit task to Celery worker queue for background processing
|
|
50
|
+
task = self.process_manager.celery_app.send_task(
|
|
51
|
+
'execute_process',
|
|
52
|
+
args=[process_id, serialized_data]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Initialize job metadata in cache with status 'accepted'
|
|
56
|
+
job_info = {
|
|
57
|
+
"status": "accepted",
|
|
58
|
+
"type": "process",
|
|
59
|
+
"process_id": process_id,
|
|
60
|
+
"created": datetime.utcnow().isoformat(),
|
|
61
|
+
"updated": datetime.utcnow().isoformat(),
|
|
62
|
+
"progress": 0
|
|
63
|
+
}
|
|
64
|
+
self.process_manager.cache.put(f"job:{task.id}", job_info)
|
|
65
|
+
|
|
66
|
+
return ProcessExecResponse(status="accepted", jobID=task.id, type="process")
|
|
67
|
+
|
|
68
|
+
class SyncExecutionStrategy(ExecutionStrategy):
|
|
69
|
+
"""Strategy for synchronous execution."""
|
|
70
|
+
|
|
71
|
+
def execute(self, process_id: str, calculation_task: CalculationTask) -> ProcessExecResponse:
|
|
72
|
+
service = self.process_manager.service_registry.get_service(process_id)
|
|
73
|
+
# TODO: response type and outputs must be passed, too
|
|
74
|
+
result = service.execute(calculation_task.inputs)
|
|
75
|
+
|
|
76
|
+
task = self.process_manager.celery_app.send_task(
|
|
77
|
+
'store_result',
|
|
78
|
+
args=[process_id, calculation_task.celery_key, result]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
job_info = {
|
|
82
|
+
"status": "successful",
|
|
83
|
+
"type": "process",
|
|
84
|
+
"process_id": process_id,
|
|
85
|
+
"created": datetime.utcnow().isoformat(),
|
|
86
|
+
"started": datetime.utcnow().isoformat(),
|
|
87
|
+
"finished": datetime.utcnow().isoformat(),
|
|
88
|
+
"updated": datetime.utcnow().isoformat(),
|
|
89
|
+
"progress": 100,
|
|
90
|
+
"result": result
|
|
91
|
+
}
|
|
92
|
+
self.process_manager.cache.put(f"job:{task.id}", job_info)
|
|
93
|
+
|
|
94
|
+
return ProcessExecResponse(status="successful", jobID=task.id, type="process", value=result)
|
|
95
|
+
|
|
96
|
+
class ProcessManager:
|
|
97
|
+
"""Manages processes, including execution, status checking, and job management."""
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
"""Initializes the ProcessManager with Celery app and service registry."""
|
|
101
|
+
self.celery_app = celery_app
|
|
102
|
+
self.service_registry = get_process_registry()
|
|
103
|
+
self.cache = redis_cache
|
|
104
|
+
|
|
105
|
+
def get_available_processes(
|
|
106
|
+
self, limit: int, offset: int
|
|
107
|
+
) -> Tuple[List[ProcessDescription], str]:
|
|
108
|
+
logger.info("Retrieving available processes")
|
|
109
|
+
"""
|
|
110
|
+
Retrieves a list of available processes.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List[ProcessDescription]: A list of process descriptions.
|
|
114
|
+
"""
|
|
115
|
+
process_ids = self.service_registry.get_service_ids()
|
|
116
|
+
processes = [
|
|
117
|
+
self.get_process_description(process_id)
|
|
118
|
+
for process_id
|
|
119
|
+
in process_ids[offset:offset+limit]
|
|
120
|
+
]
|
|
121
|
+
next_link = None
|
|
122
|
+
if offset + limit < len(process_ids):
|
|
123
|
+
next_link = f"/processes?limit={limit}&offset={offset+limit}"
|
|
124
|
+
return processes, next_link
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_process_description(self, process_id: str) -> ProcessDescription:
|
|
128
|
+
logger.info(f"Retrieving description for process ID: {process_id}")
|
|
129
|
+
"""
|
|
130
|
+
Retrieves the description of a specific process.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
process_id (str): The ID of the process.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
ProcessDescription: The description of the process.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: If the process is not found.
|
|
140
|
+
"""
|
|
141
|
+
if not self.service_registry.has_service(process_id):
|
|
142
|
+
logger.error(f"Process {process_id} not found!")
|
|
143
|
+
raise ValueError(f"Process {process_id} not found!")
|
|
144
|
+
|
|
145
|
+
service = self.service_registry.get_service(process_id)
|
|
146
|
+
return service.get_description()
|
|
147
|
+
|
|
148
|
+
def execute_process(self, process_id: str, data: ProcessExecRequestBody) -> ProcessExecResponse:
|
|
149
|
+
"""
|
|
150
|
+
Main process execution orchestration:
|
|
151
|
+
1. Validates process existence and input data
|
|
152
|
+
2. Checks result cache to avoid recomputation
|
|
153
|
+
3. Selects execution strategy (sync/async)
|
|
154
|
+
4. Delegates execution to appropriate strategy
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
process_id: Identifier for the process to execute
|
|
158
|
+
data: Contains input parameters and execution mode
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
ProcessExecResponse with job status and ID
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ValueError: If process not found or input validation fails
|
|
165
|
+
"""
|
|
166
|
+
logger.info(f"Executing process ID: {process_id}")
|
|
167
|
+
|
|
168
|
+
# Validate process exists
|
|
169
|
+
if not self.service_registry.has_service(process_id):
|
|
170
|
+
logger.error(f"Process {process_id} not found!")
|
|
171
|
+
raise ValueError(f"Process {process_id} not found!")
|
|
172
|
+
|
|
173
|
+
# Get service and validate inputs
|
|
174
|
+
service = self.service_registry.get_service(process_id)
|
|
175
|
+
try:
|
|
176
|
+
service.validate_inputs(data.inputs)
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
logger.error(f"Input validation failed for process {process_id}: {str(e)}")
|
|
179
|
+
raise ValueError(f"Input validation failed: {str(e)}")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
service.validate_outputs(data.outputs)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
logger.error(f"Output validation failed for process {process_id}: {str(e)}")
|
|
185
|
+
raise ValueError(f"Output validation failed: {str(e)}")
|
|
186
|
+
|
|
187
|
+
# Create calculation task
|
|
188
|
+
calculation_task = CalculationTask(
|
|
189
|
+
inputs=data.inputs,
|
|
190
|
+
outputs=data.outputs,
|
|
191
|
+
response=data.response
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Check cache first
|
|
195
|
+
cached_result = self._check_cache(calculation_task)
|
|
196
|
+
if cached_result:
|
|
197
|
+
return cached_result
|
|
198
|
+
|
|
199
|
+
# Select execution strategy based on mode
|
|
200
|
+
execution_strategies = {
|
|
201
|
+
ExecutionMode.SYNC: SyncExecutionStrategy(self),
|
|
202
|
+
ExecutionMode.ASYNC: AsyncExecutionStrategy(self)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
strategy: SyncExecutionStrategy | AsyncExecutionStrategy = execution_strategies[data.mode]
|
|
206
|
+
return strategy.execute(process_id, calculation_task)
|
|
207
|
+
|
|
208
|
+
def get_job_status(self, job_id: str) -> Dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Retrieves the status of a specific job.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
job_id (str): The ID of the job.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Dict[str, Any]: The status of the job.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: If the job is not found.
|
|
220
|
+
"""
|
|
221
|
+
# Check if job exists in Redis first
|
|
222
|
+
job_info = self.cache.get(f"job:{job_id}")
|
|
223
|
+
if not job_info:
|
|
224
|
+
logger.error(f"Job {job_id} not found in cache")
|
|
225
|
+
raise ValueError(f"Job {job_id} not found")
|
|
226
|
+
|
|
227
|
+
# Now check Celery status
|
|
228
|
+
result = AsyncResult(job_id)
|
|
229
|
+
if result.ready():
|
|
230
|
+
if result.successful():
|
|
231
|
+
return {"status": "successful", "type": "process"}
|
|
232
|
+
return {"status": "failed", "type": "process", "message": str(result.result)}
|
|
233
|
+
return {"status": "running", "type": "process"}
|
|
234
|
+
|
|
235
|
+
def get_job_result(self, job_id: str) -> Dict[str, Any]:
|
|
236
|
+
"""
|
|
237
|
+
Retrieves the result of a specific job.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
job_id (str): The ID of the job.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dict[str, Any]: The result of the job.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: If the job is not found.
|
|
247
|
+
"""
|
|
248
|
+
# Check if job exists in Redis first
|
|
249
|
+
job_info = self.cache.get(f"job:{job_id}")
|
|
250
|
+
if not job_info:
|
|
251
|
+
logger.error(f"Job {job_id} not found in cache")
|
|
252
|
+
raise ValueError(f"Job {job_id} not found")
|
|
253
|
+
|
|
254
|
+
result = AsyncResult(job_id)
|
|
255
|
+
if result.state == 'PENDING':
|
|
256
|
+
logger.error(f"Result for job ID {job_id} is not ready")
|
|
257
|
+
raise ValueError("Result not ready")
|
|
258
|
+
elif result.state == 'FAILURE':
|
|
259
|
+
logger.error(f"Job ID {job_id} failed with error: {result.result}")
|
|
260
|
+
raise ValueError(f"Job failed: {result.result}")
|
|
261
|
+
elif result.state == 'SUCCESS':
|
|
262
|
+
logger.info(f"Job ID {job_id} completed successfully")
|
|
263
|
+
return result.result
|
|
264
|
+
else:
|
|
265
|
+
return {"status": "running", "type": "process"}
|
|
266
|
+
|
|
267
|
+
def delete_job(self, job_id: str) -> Dict[str, Any]:
|
|
268
|
+
logger.info(f"Deleting job ID: {job_id}")
|
|
269
|
+
"""
|
|
270
|
+
Deletes a specific job.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
job_id (str): The ID of the job.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Dict[str, Any]: The status of the deletion.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: If the job is not found.
|
|
280
|
+
"""
|
|
281
|
+
result = AsyncResult(job_id)
|
|
282
|
+
if not result:
|
|
283
|
+
logger.error("Job not found")
|
|
284
|
+
raise ValueError("Job not found")
|
|
285
|
+
result.forget()
|
|
286
|
+
return {"status": "dismissed", "message": "Job dismissed"}
|
|
287
|
+
|
|
288
|
+
def get_jobs(self, limit: int, offset: int) -> List[Dict[str, Any]]:
|
|
289
|
+
"""
|
|
290
|
+
Retrieves a list of all jobs and their status.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List[Dict[str, Any]]: List of job status information
|
|
294
|
+
"""
|
|
295
|
+
# Get all job IDs from Redis
|
|
296
|
+
job_keys = self.cache.keys("job:*")
|
|
297
|
+
jobs = []
|
|
298
|
+
|
|
299
|
+
for job_key in job_keys[offset:offset+limit]:
|
|
300
|
+
try:
|
|
301
|
+
job_info = self.cache.get(job_key)
|
|
302
|
+
if job_info:
|
|
303
|
+
# Remove "job:" prefix for consistent job ID handling
|
|
304
|
+
job_id = job_key.replace("job:", "")
|
|
305
|
+
status_info = {
|
|
306
|
+
"jobID": job_id, # Clean job ID without prefix
|
|
307
|
+
"status": job_info.get("status", "unknown"),
|
|
308
|
+
"type": "process",
|
|
309
|
+
"processID": job_info.get("process_id"),
|
|
310
|
+
"created": job_info.get("created"),
|
|
311
|
+
"started": job_info.get("started"),
|
|
312
|
+
"finished": job_info.get("finished"),
|
|
313
|
+
"updated": job_info.get("updated"),
|
|
314
|
+
"progress": job_info.get("progress"),
|
|
315
|
+
"links": [
|
|
316
|
+
{
|
|
317
|
+
"href": f"/jobs/{job_id}", # Clean job ID in links
|
|
318
|
+
"rel": "self",
|
|
319
|
+
"type": "application/json"
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"href": f"/jobs/{job_id}/results", # Clean job ID in links
|
|
323
|
+
"rel": "results",
|
|
324
|
+
"type": "application/json"
|
|
325
|
+
}
|
|
326
|
+
]
|
|
327
|
+
}
|
|
328
|
+
if "message" in job_info:
|
|
329
|
+
status_info["message"] = job_info["message"]
|
|
330
|
+
jobs.append(status_info)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.error(f"Error retrieving job {job_key}: {e}")
|
|
333
|
+
|
|
334
|
+
next_link = None
|
|
335
|
+
if offset + limit < len(job_keys):
|
|
336
|
+
next_link = f"/jobs?limit={limit}&offset={offset+limit}"
|
|
337
|
+
|
|
338
|
+
return jobs, next_link
|
|
339
|
+
|
|
340
|
+
def _check_cache(self, calculation_task: CalculationTask) -> ProcessExecResponse | None:
|
|
341
|
+
"""
|
|
342
|
+
Optimizes performance by checking if identical calculation exists in cache.
|
|
343
|
+
Uses task input hash as cache key.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
calculation_task: Task containing input parameters
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Cached response if found, None otherwise
|
|
350
|
+
"""
|
|
351
|
+
cache_check = self.celery_app.send_task(
|
|
352
|
+
'check_cache',
|
|
353
|
+
args=[calculation_task.model_dump()]
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
cache_status = cache_check.get(timeout=10)
|
|
357
|
+
if cache_status["status"] != "HIT":
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
task = self.celery_app.send_task(
|
|
361
|
+
'find_result_in_cache',
|
|
362
|
+
args=[calculation_task.celery_key]
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
job_info = {
|
|
366
|
+
"status": "successful",
|
|
367
|
+
"type": "process",
|
|
368
|
+
"created": datetime.utcnow().isoformat(),
|
|
369
|
+
"started": datetime.utcnow().isoformat(),
|
|
370
|
+
"finished": datetime.utcnow().isoformat(),
|
|
371
|
+
"updated": datetime.utcnow().isoformat(),
|
|
372
|
+
"progress": 100,
|
|
373
|
+
"message": "Result retrieved from cache"
|
|
374
|
+
}
|
|
375
|
+
self.cache.put(f"job:{task.id}", job_info)
|
|
376
|
+
|
|
377
|
+
return ProcessExecResponse(status="successful", jobID=task.id, type="process")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Query, Response, status
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
|
|
4
|
+
from fastprocesses.api.manager import ProcessManager
|
|
5
|
+
from fastprocesses.core.logging import logger
|
|
6
|
+
from fastprocesses.core.models import (
|
|
7
|
+
Conformance,
|
|
8
|
+
ExecutionMode,
|
|
9
|
+
JobList,
|
|
10
|
+
Landing,
|
|
11
|
+
Link,
|
|
12
|
+
ProcessDescription,
|
|
13
|
+
ProcessExecRequestBody,
|
|
14
|
+
ProcessExecResponse,
|
|
15
|
+
ProcessList
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_router(
|
|
20
|
+
process_manager: ProcessManager,
|
|
21
|
+
title: str,
|
|
22
|
+
description: str
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
@router.get("/")
|
|
27
|
+
async def landing_page() -> Landing:
|
|
28
|
+
logger.debug("Landing page accessed")
|
|
29
|
+
return Landing(
|
|
30
|
+
title=title,
|
|
31
|
+
description=description,
|
|
32
|
+
links=[
|
|
33
|
+
Link(href="/", rel="self", type="application/json"),
|
|
34
|
+
Link(href="/conformance", rel="conformance", type="application/json"),
|
|
35
|
+
Link(href="/processes", rel="processes", type="application/json"),
|
|
36
|
+
Link(href="/jobs", rel="jobs", type="application/json"),
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@router.get("/conformance")
|
|
41
|
+
async def conformance() -> Conformance:
|
|
42
|
+
logger.debug("Conformance endpoint accessed")
|
|
43
|
+
return Conformance(
|
|
44
|
+
conformsTo=[
|
|
45
|
+
"http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core",
|
|
46
|
+
"http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/json"
|
|
47
|
+
"http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/job-list"
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@router.get(
|
|
52
|
+
"/processes",
|
|
53
|
+
response_model_exclude_none=True,
|
|
54
|
+
response_model=ProcessList
|
|
55
|
+
)
|
|
56
|
+
async def list_processes(
|
|
57
|
+
limit: int = Query(10, ge=1, le=10000),
|
|
58
|
+
offset: int = Query(0, ge=0)
|
|
59
|
+
):
|
|
60
|
+
logger.debug("List processes endpoint accessed")
|
|
61
|
+
|
|
62
|
+
processes, next_link = process_manager.get_available_processes(limit, offset)
|
|
63
|
+
links = [Link(href="/processes", rel="self", type="application/json")]
|
|
64
|
+
if next_link:
|
|
65
|
+
links.append(Link(href=next_link, rel="next", type="application/json"))
|
|
66
|
+
|
|
67
|
+
return ProcessList(
|
|
68
|
+
processes=processes,
|
|
69
|
+
links=links
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@router.get(
|
|
73
|
+
"/processes/{process_id}",
|
|
74
|
+
response_model_exclude_none=True,
|
|
75
|
+
response_model=ProcessDescription
|
|
76
|
+
)
|
|
77
|
+
async def describe_process(process_id: str):
|
|
78
|
+
logger.debug(f"Describe process endpoint accessed for process ID: {process_id}")
|
|
79
|
+
try:
|
|
80
|
+
return process_manager.get_process_description(process_id)
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
logger.error(f"Process {process_id} not found: {e}")
|
|
83
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
84
|
+
|
|
85
|
+
@router.post(
|
|
86
|
+
"/processes/{process_id}/execution",
|
|
87
|
+
response_model=ProcessExecResponse
|
|
88
|
+
)
|
|
89
|
+
async def execute_process(
|
|
90
|
+
process_id: str,
|
|
91
|
+
request: ProcessExecRequestBody,
|
|
92
|
+
response: Response
|
|
93
|
+
) -> JSONResponse:
|
|
94
|
+
logger.debug(f"Execute process endpoint accessed for process ID: {process_id}")
|
|
95
|
+
try:
|
|
96
|
+
result = process_manager.execute_process(process_id, request)
|
|
97
|
+
|
|
98
|
+
# Set response status code based on execution mode
|
|
99
|
+
if request.mode == ExecutionMode.ASYNC:
|
|
100
|
+
response.status_code = status.HTTP_201_CREATED
|
|
101
|
+
# Add Location header for async execution
|
|
102
|
+
response.headers["Location"] = f"/jobs/{result.jobID}"
|
|
103
|
+
else:
|
|
104
|
+
# For sync execution with results
|
|
105
|
+
if result.value:
|
|
106
|
+
response.status_code = status.HTTP_200_OK
|
|
107
|
+
# For sync execution without results
|
|
108
|
+
else:
|
|
109
|
+
response.status_code = status.HTTP_204_NO_CONTENT
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
error_message = str(e)
|
|
114
|
+
if "Input validation failed" in error_message:
|
|
115
|
+
logger.error(f"Input validation error for process {process_id}: {error_message}")
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
118
|
+
detail={
|
|
119
|
+
"type": "process",
|
|
120
|
+
"error": "InvalidParameterValue",
|
|
121
|
+
"message": error_message,
|
|
122
|
+
"process_id": process_id
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
logger.error(f"Process {process_id} not found: {error_message}")
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
128
|
+
detail={
|
|
129
|
+
"type": "process",
|
|
130
|
+
"error": "NotFound",
|
|
131
|
+
"message": error_message
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@router.get(
|
|
136
|
+
"/jobs",
|
|
137
|
+
response_model_exclude_none=True,
|
|
138
|
+
response_model=JobList
|
|
139
|
+
)
|
|
140
|
+
async def list_jobs(
|
|
141
|
+
limit: int = Query(10, ge=1, le=1000),
|
|
142
|
+
offset: int = Query(0, ge=0)
|
|
143
|
+
):
|
|
144
|
+
"""
|
|
145
|
+
Lists all jobs.
|
|
146
|
+
"""
|
|
147
|
+
logger.debug("List jobs endpoint accessed")
|
|
148
|
+
jobs, next_link = process_manager.get_jobs(limit, offset)
|
|
149
|
+
links = [Link(href="/jobs", rel="self", type="application/json")]
|
|
150
|
+
if next_link:
|
|
151
|
+
links.append(Link(href=next_link, rel="next", type="application/json"))
|
|
152
|
+
|
|
153
|
+
return JobList(
|
|
154
|
+
jobs=jobs,
|
|
155
|
+
links=links
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.get("/jobs/{job_id}")
|
|
160
|
+
async def get_job_status(job_id: str):
|
|
161
|
+
logger.debug(f"Get job status endpoint accessed for job ID: {job_id}")
|
|
162
|
+
try:
|
|
163
|
+
return process_manager.get_job_status(job_id)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
logger.error(f"Job {job_id} not found: {e}")
|
|
166
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
167
|
+
|
|
168
|
+
@router.get("/jobs/{job_id}/results")
|
|
169
|
+
async def get_job_result(job_id: str):
|
|
170
|
+
logger.debug(f"Get job result endpoint accessed for job ID: {job_id}")
|
|
171
|
+
try:
|
|
172
|
+
return process_manager.get_job_result(job_id)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
logger.error(f"Job {job_id} not found: {e}")
|
|
175
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
176
|
+
|
|
177
|
+
return router
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# src/fastprocesses/api/server.py
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
|
|
4
|
+
from fastprocesses.api.manager import ProcessManager
|
|
5
|
+
from fastprocesses.api.router import get_router
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OGCProcessesAPI:
|
|
9
|
+
def __init__(self, title: str, version: str, description: str):
|
|
10
|
+
self.process_manager = ProcessManager()
|
|
11
|
+
self.app = FastAPI(
|
|
12
|
+
title=title,
|
|
13
|
+
version=version,
|
|
14
|
+
description=description
|
|
15
|
+
)
|
|
16
|
+
self.app.include_router(
|
|
17
|
+
get_router(
|
|
18
|
+
self.process_manager,
|
|
19
|
+
self.app.title,
|
|
20
|
+
self.app.description)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def get_app(self) -> FastAPI:
|
|
24
|
+
return self.app
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import signal
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from fastprocesses.core.logging import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
logger.info("Starting Celery worker")
|
|
10
|
+
# Construct the Celery command
|
|
11
|
+
celery_command = [
|
|
12
|
+
"celery",
|
|
13
|
+
"-A",
|
|
14
|
+
"src.fastprocesses.worker.celery_app",
|
|
15
|
+
"worker",
|
|
16
|
+
"--loglevel=info"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# Append any additional arguments passed to the script
|
|
20
|
+
celery_command.extend(sys.argv[1:])
|
|
21
|
+
|
|
22
|
+
# Start the Celery process
|
|
23
|
+
process = subprocess.Popen(celery_command)
|
|
24
|
+
logger.info("Celery worker started")
|
|
25
|
+
|
|
26
|
+
def handle_signal(sig, frame):
|
|
27
|
+
logger.info(f"Received signal: {sig}")
|
|
28
|
+
# Forward the signal to the Celery process
|
|
29
|
+
process.send_signal(sig)
|
|
30
|
+
|
|
31
|
+
# Register signal handlers
|
|
32
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
33
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
# Wait for the Celery process to complete
|
|
37
|
+
process.wait()
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
logger.info("KeyboardInterrupt received, shutting down Celery worker")
|
|
40
|
+
# Handle the KeyboardInterrupt to ensure a warm shutdown
|
|
41
|
+
process.send_signal(signal.SIGINT)
|
|
42
|
+
process.wait()
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
fastprocesses/common.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from celery import Celery
|
|
4
|
+
from fastapi.encoders import jsonable_encoder
|
|
5
|
+
from kombu.serialization import register
|
|
6
|
+
|
|
7
|
+
from fastprocesses.core.cache import Cache
|
|
8
|
+
from fastprocesses.core.config import settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def custom_json_serializer(obj):
|
|
12
|
+
# Use jsonable_encoder to handle complex objects
|
|
13
|
+
return json.dumps(jsonable_encoder(obj))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def custom_json_deserializer(data):
|
|
17
|
+
# Deserialize JSON back into Python objects
|
|
18
|
+
return json.loads(data)
|
|
19
|
+
|
|
20
|
+
# Register the custom serializer
|
|
21
|
+
register(
|
|
22
|
+
"custom_json",
|
|
23
|
+
custom_json_serializer,
|
|
24
|
+
custom_json_deserializer,
|
|
25
|
+
content_type="application/x-custom-json",
|
|
26
|
+
content_encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
celery_app = Celery(
|
|
30
|
+
"ogc_processes",
|
|
31
|
+
broker=str(settings.celery_broker_url),
|
|
32
|
+
backend=str(settings.celery_result_backend),
|
|
33
|
+
include=["fastprocesses.worker.celery_app"], # Ensure the module is included
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
celery_app.conf.update(
|
|
37
|
+
task_serializer="custom_json",
|
|
38
|
+
result_serializer="custom_json",
|
|
39
|
+
accept_content=["custom_json", "json"], # Accept only the custom serializer
|
|
40
|
+
timezone="UTC",
|
|
41
|
+
enable_utc=True,
|
|
42
|
+
task_routes={
|
|
43
|
+
"fastprocesses.worker.celery_app.execute_process": {
|
|
44
|
+
"queue": "process_tasks",
|
|
45
|
+
"routing_key": "process_tasks",
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
broker_connection_retry=True,
|
|
49
|
+
broker_connection_retry_on_startup=True,
|
|
50
|
+
# set limits for long-running tasks
|
|
51
|
+
task_time_limit=900, # Hard limit in seconds
|
|
52
|
+
task_soft_time_limit=600, # Soft limit in seconds
|
|
53
|
+
result_expires=3153600, # 1 year in seconds
|
|
54
|
+
worker_send_task_events=True, # Enable events to track task progress
|
|
55
|
+
# task_acks_late=True, # Acknowledge the task only after it has been executed)
|
|
56
|
+
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
redis_cache = Cache(key_prefix="process_results", ttl_days=7)
|
|
File without changes
|