comet-hunter 0.1.0__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.
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +77 -0
- backend/api/dto/__init__.py +0 -0
- backend/api/dto/api_response.py +17 -0
- backend/api/dto/response_models.py +65 -0
- backend/api/dto/serializers.py +31 -0
- backend/api/middleware.py +44 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/frames.py +104 -0
- backend/api/routes/health.py +53 -0
- backend/api/routes/jobs.py +274 -0
- backend/api/routes/scheduler.py +52 -0
- backend/api/routes/slots.py +97 -0
- backend/config.py +22 -0
- backend/core/__init__.py +0 -0
- backend/core/logging_config.py +31 -0
- backend/core/request_context.py +9 -0
- backend/core/storage.py +15 -0
- backend/database/__init__.py +0 -0
- backend/database/domain/__init__.py +0 -0
- backend/database/domain/downlink_slot.py +77 -0
- backend/database/domain/file_metadata.py +77 -0
- backend/database/domain/processed_file.py +238 -0
- backend/database/infrastructure/__init__.py +0 -0
- backend/database/infrastructure/base.py +60 -0
- backend/database/infrastructure/bootstrap.py +16 -0
- backend/database/infrastructure/exceptions.py +14 -0
- backend/database/infrastructure/query_executor.py +84 -0
- backend/database/infrastructure/query_result.py +18 -0
- backend/database/infrastructure/query_spec.py +35 -0
- backend/database/repositories/__init__.py +0 -0
- backend/database/repositories/downlink_slot_repository.py +465 -0
- backend/database/repositories/file_metadata_repository.py +463 -0
- backend/database/repositories/processed_file_repository.py +563 -0
- backend/jobs/__init__.py +0 -0
- backend/jobs/background_job_service.py +81 -0
- backend/jobs/event_bus.py +109 -0
- backend/jobs/event_models.py +42 -0
- backend/jobs/exceptions.py +4 -0
- backend/jobs/job.py +18 -0
- backend/jobs/job_store.py +148 -0
- backend/jobs/job_submission.py +7 -0
- backend/jobs/runner.py +102 -0
- backend/main.py +80 -0
- backend/pipeline/__init__.py +0 -0
- backend/pipeline/models.py +49 -0
- backend/pipeline/pipeline.py +353 -0
- backend/pipeline/scheduler.py +98 -0
- backend/services/__init__.py +0 -0
- backend/services/download_file_service.py +382 -0
- backend/services/metadata_service.py +397 -0
- backend/services/process_file_service.py +511 -0
- backend/services/slot_service.py +300 -0
- backend/util/__init__.py +0 -0
- backend/util/constants.py +73 -0
- backend/util/enums.py +115 -0
- backend/util/funcs.py +27 -0
- comet_hunter/__init__.py +1 -0
- comet_hunter/cli.py +133 -0
- comet_hunter/launcher.py +34 -0
- comet_hunter/process_store.py +35 -0
- comet_hunter-0.1.0.dist-info/METADATA +159 -0
- comet_hunter-0.1.0.dist-info/RECORD +98 -0
- comet_hunter-0.1.0.dist-info/WHEEL +5 -0
- comet_hunter-0.1.0.dist-info/entry_points.txt +2 -0
- comet_hunter-0.1.0.dist-info/licenses/LICENSE +21 -0
- comet_hunter-0.1.0.dist-info/top_level.txt +3 -0
- frontend/__init__.py +0 -0
- frontend/app.py +90 -0
- frontend/components/__init__.py +0 -0
- frontend/components/active_slot_panel.py +47 -0
- frontend/components/event_stream_panel.py +143 -0
- frontend/components/footer.py +11 -0
- frontend/components/get_frames_panel.py +315 -0
- frontend/components/health_panel.py +29 -0
- frontend/components/image_panel.py +137 -0
- frontend/components/navbar.py +15 -0
- frontend/components/scheduler_panel.py +220 -0
- frontend/components/sync_frames_panel.py +437 -0
- frontend/components/sync_slots_panel.py +123 -0
- frontend/components/utc_clock.py +18 -0
- frontend/config.py +20 -0
- frontend/events/__init__.py +0 -0
- frontend/events/sse.py +129 -0
- frontend/models/__init__.py +0 -0
- frontend/models/frame.py +8 -0
- frontend/models/instruments.py +5 -0
- frontend/models/job.py +16 -0
- frontend/models/processed_file.py +8 -0
- frontend/models/slot.py +6 -0
- frontend/services/__init__.py +0 -0
- frontend/services/api.py +279 -0
- frontend/state/__init__.py +0 -0
- frontend/state/event_store.py +33 -0
- frontend/state/frame_store.py +86 -0
- frontend/styles/__init__.py +0 -0
- frontend/styles/theme.py +78 -0
backend/__init__.py
ADDED
|
File without changes
|
backend/api/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from backend.database.infrastructure.query_executor import QueryExecutor
|
|
3
|
+
from backend.database.repositories.file_metadata_repository import FileMetadataRepository
|
|
4
|
+
from backend.database.repositories.processed_file_repository import ProcessedFileRepository
|
|
5
|
+
from backend.database.repositories.downlink_slot_repository import DownlinkSlotRepository
|
|
6
|
+
from backend.services.metadata_service import MetadataService
|
|
7
|
+
from backend.services.download_file_service import DownloadFileService
|
|
8
|
+
from backend.services.process_file_service import ProcessFileService
|
|
9
|
+
from backend.services.slot_service import SlotService
|
|
10
|
+
from backend.pipeline.pipeline import Pipeline
|
|
11
|
+
from backend.pipeline.scheduler import Scheduler
|
|
12
|
+
from backend.jobs.job_store import job_store, JobStore
|
|
13
|
+
from backend.jobs.background_job_service import BackgroundJobService
|
|
14
|
+
from backend.jobs.event_bus import event_bus, EventBus
|
|
15
|
+
from backend.core.storage import RAW_DIR, PROCESSED_DIR
|
|
16
|
+
|
|
17
|
+
background_job_service = BackgroundJobService(job_store)
|
|
18
|
+
|
|
19
|
+
executor = QueryExecutor()
|
|
20
|
+
|
|
21
|
+
metadata_repo = FileMetadataRepository(executor)
|
|
22
|
+
processed_repo = ProcessedFileRepository(executor)
|
|
23
|
+
slot_repo = DownlinkSlotRepository(executor)
|
|
24
|
+
|
|
25
|
+
metadata_service = MetadataService(metadata_repo)
|
|
26
|
+
slot_service = SlotService(slot_repo)
|
|
27
|
+
download_service = DownloadFileService(
|
|
28
|
+
processed_repository=processed_repo,
|
|
29
|
+
metadata_service=metadata_service,
|
|
30
|
+
download_directory=RAW_DIR,
|
|
31
|
+
)
|
|
32
|
+
process_service = ProcessFileService(
|
|
33
|
+
processed_repository=processed_repo,
|
|
34
|
+
metadata_service=metadata_service,
|
|
35
|
+
processed_directory=PROCESSED_DIR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
pipeline = Pipeline(
|
|
39
|
+
slot_service=slot_service,
|
|
40
|
+
metadata_service=metadata_service,
|
|
41
|
+
download_service=download_service,
|
|
42
|
+
process_service=process_service,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
scheduler = Scheduler(pipeline)
|
|
46
|
+
|
|
47
|
+
def get_pipeline() -> Pipeline:
|
|
48
|
+
"""
|
|
49
|
+
Returns shared pipeline instance for handling API requests and background jobs.
|
|
50
|
+
"""
|
|
51
|
+
return pipeline
|
|
52
|
+
|
|
53
|
+
def get_scheduler() -> Scheduler:
|
|
54
|
+
"""
|
|
55
|
+
Returns shared scheduler instance for triggering background jobs.
|
|
56
|
+
"""
|
|
57
|
+
return scheduler
|
|
58
|
+
|
|
59
|
+
def get_job_store() -> JobStore:
|
|
60
|
+
"""
|
|
61
|
+
Returns a shared job_store instance.
|
|
62
|
+
"""
|
|
63
|
+
return job_store
|
|
64
|
+
|
|
65
|
+
def get_job_service() -> BackgroundJobService:
|
|
66
|
+
"""
|
|
67
|
+
Returns shared background job service instance.
|
|
68
|
+
Used for submitting and managing background jobs.
|
|
69
|
+
"""
|
|
70
|
+
return background_job_service
|
|
71
|
+
|
|
72
|
+
def get_event_bus() -> EventBus:
|
|
73
|
+
"""
|
|
74
|
+
Returns shared event bus instance.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return event_bus
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from pydantic.generics import GenericModel
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar('T')
|
|
6
|
+
|
|
7
|
+
class ApiSuccessResponse(GenericModel, Generic[T]):
|
|
8
|
+
success: bool = True
|
|
9
|
+
data: T
|
|
10
|
+
|
|
11
|
+
class ApiErrorDetail(BaseModel):
|
|
12
|
+
code: str
|
|
13
|
+
message: str
|
|
14
|
+
|
|
15
|
+
class ApiErrorResponse(BaseModel):
|
|
16
|
+
success: bool = False
|
|
17
|
+
error: ApiErrorDetail
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from backend.util.enums import Instrument, JobType, JobStatus
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Optional, Any
|
|
5
|
+
|
|
6
|
+
class ProcessedFileResponse(BaseModel):
|
|
7
|
+
processed_file_name: str
|
|
8
|
+
instrument: Instrument
|
|
9
|
+
processed_file_url: str
|
|
10
|
+
datetime_of_observation: datetime
|
|
11
|
+
|
|
12
|
+
class GetFramesResponse(BaseModel):
|
|
13
|
+
files: list[ProcessedFileResponse]
|
|
14
|
+
total: int
|
|
15
|
+
limit: int
|
|
16
|
+
offset: int
|
|
17
|
+
|
|
18
|
+
class SyncFramesResponse(BaseModel):
|
|
19
|
+
metadata_synced: int
|
|
20
|
+
downloaded: int
|
|
21
|
+
marked_ready: int
|
|
22
|
+
processed: int
|
|
23
|
+
|
|
24
|
+
class SyncSlotsResponse(BaseModel):
|
|
25
|
+
status: str
|
|
26
|
+
slots_synced: int
|
|
27
|
+
|
|
28
|
+
class SchedulerStatusResponse(BaseModel):
|
|
29
|
+
running: bool
|
|
30
|
+
next_run_at: Optional[datetime]
|
|
31
|
+
next_run_in: Optional[timedelta]
|
|
32
|
+
|
|
33
|
+
class SchedulerStartResponse(BaseModel):
|
|
34
|
+
started: bool
|
|
35
|
+
running: bool
|
|
36
|
+
|
|
37
|
+
class SchedulerStopResponse(BaseModel):
|
|
38
|
+
stopped: bool
|
|
39
|
+
running: bool
|
|
40
|
+
|
|
41
|
+
class HealthResponse(BaseModel):
|
|
42
|
+
status: str
|
|
43
|
+
database: bool
|
|
44
|
+
scheduler_initialized: bool
|
|
45
|
+
pipeline_initialized: bool
|
|
46
|
+
|
|
47
|
+
class SlotResponse(BaseModel):
|
|
48
|
+
start: Optional[datetime]
|
|
49
|
+
end: Optional[datetime]
|
|
50
|
+
|
|
51
|
+
class JobQueuedResponse(BaseModel):
|
|
52
|
+
job_id: str
|
|
53
|
+
existing: bool
|
|
54
|
+
status: str
|
|
55
|
+
|
|
56
|
+
class JobStatusResponse(BaseModel):
|
|
57
|
+
job_id: str
|
|
58
|
+
type: JobType
|
|
59
|
+
status: JobStatus
|
|
60
|
+
created_at: datetime
|
|
61
|
+
started_at: Optional[datetime] = None
|
|
62
|
+
stopped_at: Optional[datetime] = None
|
|
63
|
+
progress: Optional[Any] = None
|
|
64
|
+
result: Optional[Any] = None
|
|
65
|
+
error: Optional[str] = None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from backend.api.dto.response_models import GetFramesResponse, ProcessedFileResponse
|
|
2
|
+
from backend.database.domain.processed_file import ProcessedFile
|
|
3
|
+
from backend.pipeline.models import GetProcessedFramesResult
|
|
4
|
+
|
|
5
|
+
def serialize_processed_file(
|
|
6
|
+
file: ProcessedFile
|
|
7
|
+
) -> ProcessedFileResponse:
|
|
8
|
+
"""
|
|
9
|
+
Converts ProcessedFile domain model to ProcessedFileResponse DTO
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
return ProcessedFileResponse(
|
|
13
|
+
processed_file_name=file.processed_file_name,
|
|
14
|
+
instrument=file.instrument,
|
|
15
|
+
processed_file_url=f"/media/{str(file.processed_file_name)}",
|
|
16
|
+
datetime_of_observation=file.datetime_of_observation
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def serialize_get_frames_response(result: GetProcessedFramesResult) -> GetFramesResponse:
|
|
20
|
+
"""
|
|
21
|
+
Converts GetProcessedFramesResult pipeline result domain model to GetFramesResponse DTO
|
|
22
|
+
"""
|
|
23
|
+
return GetFramesResponse(
|
|
24
|
+
files=[
|
|
25
|
+
serialize_processed_file(file)
|
|
26
|
+
for file in result.processed_files
|
|
27
|
+
],
|
|
28
|
+
total=result.total,
|
|
29
|
+
limit=result.limit,
|
|
30
|
+
offset=result.offset
|
|
31
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
5
|
+
from backend.core.request_context import set_request_id
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("api")
|
|
8
|
+
|
|
9
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
10
|
+
|
|
11
|
+
async def dispatch(self, request, call_next):
|
|
12
|
+
request_id = str(uuid.uuid4())
|
|
13
|
+
set_request_id(request_id)
|
|
14
|
+
|
|
15
|
+
start = time.time()
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
response = await call_next(request)
|
|
19
|
+
duration = time.time() - start
|
|
20
|
+
|
|
21
|
+
logger.info(
|
|
22
|
+
"Request completed",
|
|
23
|
+
extra={
|
|
24
|
+
"request_id": request_id,
|
|
25
|
+
"method": request.method,
|
|
26
|
+
"path": request.url.path,
|
|
27
|
+
"status": response.status_code,
|
|
28
|
+
"duration_ms": round(duration * 1000, 2)
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
except Exception:
|
|
35
|
+
logger.exception(
|
|
36
|
+
"Request failed",
|
|
37
|
+
extra={
|
|
38
|
+
"request_id": request_id,
|
|
39
|
+
"method": request.method,
|
|
40
|
+
"path": request.url.path
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
raise
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from fastapi import APIRouter, Query, Depends
|
|
2
|
+
from backend.api.dependencies import get_pipeline, get_job_service
|
|
3
|
+
from backend.util.enums import Instrument, JobType
|
|
4
|
+
from backend.api.dto.api_response import ApiSuccessResponse
|
|
5
|
+
from backend.api.dto.response_models import JobQueuedResponse, GetFramesResponse
|
|
6
|
+
from backend.pipeline.pipeline import Pipeline
|
|
7
|
+
from backend.jobs.background_job_service import BackgroundJobService
|
|
8
|
+
from backend.api.dto.serializers import serialize_get_frames_response
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
@router.get("/", response_model=ApiSuccessResponse[GetFramesResponse])
|
|
15
|
+
def get_processed_frames(
|
|
16
|
+
instrument: Instrument = Query(...),
|
|
17
|
+
start: str = Query(..., description = "ISO UTC observation start time"),
|
|
18
|
+
end: str = Query(..., description = "ISO UTC observation end time"),
|
|
19
|
+
limit: int = Query(15, ge=1, le=100),
|
|
20
|
+
offset: int = Query(0, ge=0),
|
|
21
|
+
pipeline: Pipeline = Depends(get_pipeline)
|
|
22
|
+
) -> ApiSuccessResponse[GetFramesResponse]:
|
|
23
|
+
"""
|
|
24
|
+
Retrieve of processed frames for a given observation window and instrument
|
|
25
|
+
"""
|
|
26
|
+
logger.info(
|
|
27
|
+
"Processed frames retrieval requested",
|
|
28
|
+
extra={
|
|
29
|
+
"instrument":instrument,
|
|
30
|
+
"observation_start_utc":start,
|
|
31
|
+
"observation_end_utc":end
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
|
|
36
|
+
result = pipeline.get_processed_frames(
|
|
37
|
+
instrument,
|
|
38
|
+
start,
|
|
39
|
+
end,
|
|
40
|
+
limit,
|
|
41
|
+
offset
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger.info("Processed frames retrieval successful")
|
|
45
|
+
|
|
46
|
+
return ApiSuccessResponse[GetFramesResponse](
|
|
47
|
+
data=serialize_get_frames_response(result)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.exception("Processed frames retrieval failed")
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
@router.post("/sync", response_model=ApiSuccessResponse[JobQueuedResponse])
|
|
55
|
+
def sync_processed_frames(
|
|
56
|
+
instrument: Instrument = Query(...),
|
|
57
|
+
start: str = Query(..., description = "ISO UTC observation start time"),
|
|
58
|
+
end: str = Query(..., description = "ISO UTC observation end time"),
|
|
59
|
+
pipeline: Pipeline = Depends(get_pipeline),
|
|
60
|
+
background_job_service: BackgroundJobService = Depends(get_job_service)
|
|
61
|
+
) -> ApiSuccessResponse[JobQueuedResponse]:
|
|
62
|
+
"""
|
|
63
|
+
Trigger the job for syncing and updating processed frames based on
|
|
64
|
+
instrument and observation time
|
|
65
|
+
|
|
66
|
+
- Triggers metadata sync
|
|
67
|
+
- Downloads missing files
|
|
68
|
+
- Processes eligible frames
|
|
69
|
+
- Returns details of the sync operation including counts of metadata synced, files downloaded, frames marked ready and frames processed
|
|
70
|
+
"""
|
|
71
|
+
logger.info(
|
|
72
|
+
"Processed frames sync job requested",
|
|
73
|
+
extra={
|
|
74
|
+
"instrument":instrument,
|
|
75
|
+
"observation_start_utc":start,
|
|
76
|
+
"observation_end_utc":end
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
job = background_job_service.submit(
|
|
81
|
+
JobType.SYNC_PROCESSED_FRAMES,
|
|
82
|
+
pipeline.sync_processed_frames,
|
|
83
|
+
instrument,
|
|
84
|
+
start,
|
|
85
|
+
end
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if job.existing:
|
|
89
|
+
logger.warning("Processed frames sync job already running",
|
|
90
|
+
extra={"job_id": job.job.id})
|
|
91
|
+
else:
|
|
92
|
+
logger.info("Processed frames sync job queued",
|
|
93
|
+
extra={"job_id": job.job.id})
|
|
94
|
+
|
|
95
|
+
return ApiSuccessResponse[JobQueuedResponse](
|
|
96
|
+
data=JobQueuedResponse(
|
|
97
|
+
job_id=job.job.id,
|
|
98
|
+
existing=job.existing,
|
|
99
|
+
status=job.job.status.value
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
except Exception:
|
|
103
|
+
logger.exception("Processed frames sync failed to start")
|
|
104
|
+
raise
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from fastapi import APIRouter, Depends
|
|
3
|
+
from backend.api.dto.api_response import ApiSuccessResponse
|
|
4
|
+
from backend.api.dto.response_models import HealthResponse
|
|
5
|
+
from backend.api.dependencies import get_pipeline, get_scheduler
|
|
6
|
+
from backend.pipeline.pipeline import Pipeline
|
|
7
|
+
from backend.pipeline.scheduler import Scheduler
|
|
8
|
+
from backend.database.infrastructure.base import DatabaseBase
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@router.get("/", response_model=ApiSuccessResponse[HealthResponse])
|
|
14
|
+
def health(
|
|
15
|
+
pipeline: Pipeline = Depends(get_pipeline),
|
|
16
|
+
scheduler: Scheduler = Depends(get_scheduler)
|
|
17
|
+
) -> ApiSuccessResponse[HealthResponse]:
|
|
18
|
+
"""
|
|
19
|
+
Checks server health and verifies initialization of
|
|
20
|
+
- database connectivity
|
|
21
|
+
- scheduler status
|
|
22
|
+
- pipeline availability
|
|
23
|
+
"""
|
|
24
|
+
logger.debug("Health check requested")
|
|
25
|
+
try:
|
|
26
|
+
with DatabaseBase.get_connection() as conn:
|
|
27
|
+
conn.execute("SELECT 1")
|
|
28
|
+
db_ok = True
|
|
29
|
+
except Exception:
|
|
30
|
+
db_ok = False
|
|
31
|
+
|
|
32
|
+
scheduler_ok = scheduler is not None
|
|
33
|
+
pipeline_ok = pipeline is not None
|
|
34
|
+
healthy = db_ok and scheduler_ok and pipeline_ok
|
|
35
|
+
status = "healthy" if healthy else "degraded"
|
|
36
|
+
|
|
37
|
+
logger.debug(
|
|
38
|
+
"Health check completed",
|
|
39
|
+
extra={
|
|
40
|
+
"status": status,
|
|
41
|
+
"database": db_ok,
|
|
42
|
+
"scheduler_initialized": scheduler_ok,
|
|
43
|
+
"pipeline_initialized": pipeline_ok
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
return ApiSuccessResponse[HealthResponse](
|
|
47
|
+
data = HealthResponse(
|
|
48
|
+
status=status,
|
|
49
|
+
database=db_ok,
|
|
50
|
+
scheduler_initialized=scheduler_ok,
|
|
51
|
+
pipeline_initialized=pipeline_ok
|
|
52
|
+
)
|
|
53
|
+
)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
3
|
+
from fastapi.responses import StreamingResponse
|
|
4
|
+
from queue import Empty
|
|
5
|
+
from typing import Generator
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from backend.jobs.background_job_service import BackgroundJobService
|
|
10
|
+
from backend.jobs.event_bus import EventBus
|
|
11
|
+
from backend.jobs.job_store import JobStore
|
|
12
|
+
from backend.api.dependencies import get_job_service, get_job_store, get_event_bus
|
|
13
|
+
from backend.api.dto.api_response import ApiSuccessResponse
|
|
14
|
+
from backend.api.dto.response_models import JobStatusResponse
|
|
15
|
+
from backend.util.enums import JobStatus
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
@router.get("/{job_id}", response_model=ApiSuccessResponse[JobStatusResponse])
|
|
21
|
+
def get_job(
|
|
22
|
+
job_id: str,
|
|
23
|
+
background_job_service: BackgroundJobService = Depends(get_job_service)
|
|
24
|
+
) -> ApiSuccessResponse[JobStatusResponse]:
|
|
25
|
+
"""
|
|
26
|
+
Retrieve background job execution details.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
- current lifecycle status
|
|
30
|
+
- result payload if completed
|
|
31
|
+
- failure error if failed
|
|
32
|
+
|
|
33
|
+
:param job_id:
|
|
34
|
+
Unique identifier of the background job.
|
|
35
|
+
|
|
36
|
+
:param background_job_service:
|
|
37
|
+
Background job service dependency.
|
|
38
|
+
|
|
39
|
+
:raises HTTPException:
|
|
40
|
+
404 if job does not exist.
|
|
41
|
+
|
|
42
|
+
:return:
|
|
43
|
+
Standardized API response containing job details.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
logger.info(
|
|
47
|
+
"Job status requested",
|
|
48
|
+
extra={"job_id": job_id}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
|
|
53
|
+
job = background_job_service.get_job(job_id)
|
|
54
|
+
|
|
55
|
+
if job is None:
|
|
56
|
+
|
|
57
|
+
logger.warning(
|
|
58
|
+
"Job not found",
|
|
59
|
+
extra={"job_id": job_id}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=404,
|
|
64
|
+
detail="Job not found"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger.info(
|
|
68
|
+
"Job status retrieved",
|
|
69
|
+
extra={
|
|
70
|
+
"job_id": job.id,
|
|
71
|
+
"job_type": job.type,
|
|
72
|
+
"job_status": job.status.value
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return ApiSuccessResponse[JobStatusResponse](
|
|
77
|
+
data=JobStatusResponse(
|
|
78
|
+
job_id=job.id,
|
|
79
|
+
type=job.type.value,
|
|
80
|
+
status=job.status.value,
|
|
81
|
+
created_at=job.created_at,
|
|
82
|
+
started_at=job.started_at,
|
|
83
|
+
stopped_at=job.stopped_at,
|
|
84
|
+
progress=job.progress,
|
|
85
|
+
result=job.result,
|
|
86
|
+
error=job.error
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
except HTTPException:
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
except Exception:
|
|
94
|
+
|
|
95
|
+
logger.exception(
|
|
96
|
+
"Failed to retrieve job status",
|
|
97
|
+
extra={"job_id": job_id}
|
|
98
|
+
)
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
@router.get("/{job_id}/events")
|
|
102
|
+
def stream_job_events(
|
|
103
|
+
job_id: str,
|
|
104
|
+
event_bus: EventBus = Depends(get_event_bus)
|
|
105
|
+
) -> StreamingResponse:
|
|
106
|
+
"""
|
|
107
|
+
Stream real-time events using server-sent events.
|
|
108
|
+
|
|
109
|
+
Responsibilities:
|
|
110
|
+
- Subscribe client to job-specific event stream
|
|
111
|
+
- Continously stream published events.
|
|
112
|
+
- Cleanup subscription on client disconnect or job completion.
|
|
113
|
+
|
|
114
|
+
Event format:
|
|
115
|
+
"event": <event_type>,
|
|
116
|
+
"data": <json_payload>
|
|
117
|
+
|
|
118
|
+
Example streamed payload:
|
|
119
|
+
"event": "progress",
|
|
120
|
+
"data":{"download": 12}
|
|
121
|
+
|
|
122
|
+
Notes:
|
|
123
|
+
- Connection remains open until job completion or client disconnect.
|
|
124
|
+
- Intended for real-time monitoring of long-running jobs.
|
|
125
|
+
- Uses in-memory event bus subscriptions.
|
|
126
|
+
- Each connected client gets dedicated queue.
|
|
127
|
+
|
|
128
|
+
:param job_id:
|
|
129
|
+
Unique identifier of the background job.
|
|
130
|
+
|
|
131
|
+
:param event_bus:
|
|
132
|
+
Shared event bus dependency.
|
|
133
|
+
|
|
134
|
+
:return:
|
|
135
|
+
StreamingResponse configured for server-sent events.
|
|
136
|
+
"""
|
|
137
|
+
logger.info(
|
|
138
|
+
"Job event stream requested",
|
|
139
|
+
extra={"job_id": job_id}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def event_generator() -> Generator[str, None, None]:
|
|
143
|
+
"""
|
|
144
|
+
Generate SSE-formatted event stream.
|
|
145
|
+
|
|
146
|
+
Workflow:
|
|
147
|
+
- Subscribe to event bus.
|
|
148
|
+
- Wait for events.
|
|
149
|
+
- Yield events in SSE format.
|
|
150
|
+
- Cleanup / Disconnect.
|
|
151
|
+
|
|
152
|
+
Yields:
|
|
153
|
+
str: SSE-formatted event string.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
queue = event_bus.subscribe(job_id)
|
|
157
|
+
|
|
158
|
+
terminal_events = {
|
|
159
|
+
JobStatus.COMPLETED,
|
|
160
|
+
JobStatus.FAILED,
|
|
161
|
+
JobStatus.CANCELLED
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
event = queue.get(timeout=5)
|
|
168
|
+
|
|
169
|
+
payload = asdict(event)
|
|
170
|
+
payload["event"] = payload["event"].value
|
|
171
|
+
payload["job_status"] = payload["job_status"].value
|
|
172
|
+
payload["job_type"] = payload["job_type"].value
|
|
173
|
+
payload["timestamp"] = payload["timestamp"].isoformat()
|
|
174
|
+
|
|
175
|
+
yield (
|
|
176
|
+
f"event: {payload["event"]}\n"
|
|
177
|
+
f"data: {json.dumps(payload)}\n\n"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if event.job_status in terminal_events:
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
except Empty:
|
|
184
|
+
"""
|
|
185
|
+
Heartbeat to keep connection alive.
|
|
186
|
+
|
|
187
|
+
Prevents:
|
|
188
|
+
- proxy timeout
|
|
189
|
+
- connection idle termination
|
|
190
|
+
"""
|
|
191
|
+
yield ": keepalive\n\n"
|
|
192
|
+
|
|
193
|
+
time.sleep(0.1)
|
|
194
|
+
except GeneratorExit:
|
|
195
|
+
logger.info(
|
|
196
|
+
"job event stream disconnected",
|
|
197
|
+
extra={"job_id": job_id}
|
|
198
|
+
)
|
|
199
|
+
finally:
|
|
200
|
+
|
|
201
|
+
event_bus.unsubscribe(job_id, queue)
|
|
202
|
+
logger.info(
|
|
203
|
+
"job event subscription cleaned up",
|
|
204
|
+
extra={"job_id": job_id}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return StreamingResponse(
|
|
208
|
+
content = event_generator(),
|
|
209
|
+
media_type = "text/event-stream",
|
|
210
|
+
headers = {
|
|
211
|
+
"Cache-Control": "no-cache",
|
|
212
|
+
"Connection": "keep-alive",
|
|
213
|
+
"X-Accel-Buffering": "no"
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@router.post("/{job_id}/cancel", response_model=ApiSuccessResponse[dict])
|
|
218
|
+
def cancel_job(
|
|
219
|
+
job_id: str,
|
|
220
|
+
job_store: JobStore = Depends(get_job_store)
|
|
221
|
+
) -> ApiSuccessResponse[dict]:
|
|
222
|
+
"""
|
|
223
|
+
Trigger cancellation request for a running job.
|
|
224
|
+
|
|
225
|
+
Notes:
|
|
226
|
+
- Cancellation is cooperative.
|
|
227
|
+
- Actual job termination depends on cancellation checkpoints.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
logger.info(
|
|
231
|
+
"Job Cancellation triggered",
|
|
232
|
+
extra={"job_id": job_id}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
|
|
237
|
+
job = job_store.get_job(job_id)
|
|
238
|
+
|
|
239
|
+
if not job:
|
|
240
|
+
logger.warning(
|
|
241
|
+
"Job not found",
|
|
242
|
+
extra={"job_id": job_id}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
raise HTTPException(
|
|
246
|
+
status_code=404,
|
|
247
|
+
detail="job not found"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
job.cancel_event.set()
|
|
251
|
+
|
|
252
|
+
job_store.update_job(
|
|
253
|
+
job_id,
|
|
254
|
+
JobStatus.CANCELLING
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
logger.info(
|
|
258
|
+
"Job cancellation signal sent",
|
|
259
|
+
extra={"job_id":job_id}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return ApiSuccessResponse(
|
|
263
|
+
data={
|
|
264
|
+
"job_id":job_id,
|
|
265
|
+
"message":"Cancellation Signal Sent"
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
except HTTPException:
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
except Exception:
|
|
273
|
+
logger.exception("Job cancellation failed")
|
|
274
|
+
raise
|