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.
Files changed (98) hide show
  1. backend/__init__.py +0 -0
  2. backend/api/__init__.py +0 -0
  3. backend/api/dependencies.py +77 -0
  4. backend/api/dto/__init__.py +0 -0
  5. backend/api/dto/api_response.py +17 -0
  6. backend/api/dto/response_models.py +65 -0
  7. backend/api/dto/serializers.py +31 -0
  8. backend/api/middleware.py +44 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/frames.py +104 -0
  11. backend/api/routes/health.py +53 -0
  12. backend/api/routes/jobs.py +274 -0
  13. backend/api/routes/scheduler.py +52 -0
  14. backend/api/routes/slots.py +97 -0
  15. backend/config.py +22 -0
  16. backend/core/__init__.py +0 -0
  17. backend/core/logging_config.py +31 -0
  18. backend/core/request_context.py +9 -0
  19. backend/core/storage.py +15 -0
  20. backend/database/__init__.py +0 -0
  21. backend/database/domain/__init__.py +0 -0
  22. backend/database/domain/downlink_slot.py +77 -0
  23. backend/database/domain/file_metadata.py +77 -0
  24. backend/database/domain/processed_file.py +238 -0
  25. backend/database/infrastructure/__init__.py +0 -0
  26. backend/database/infrastructure/base.py +60 -0
  27. backend/database/infrastructure/bootstrap.py +16 -0
  28. backend/database/infrastructure/exceptions.py +14 -0
  29. backend/database/infrastructure/query_executor.py +84 -0
  30. backend/database/infrastructure/query_result.py +18 -0
  31. backend/database/infrastructure/query_spec.py +35 -0
  32. backend/database/repositories/__init__.py +0 -0
  33. backend/database/repositories/downlink_slot_repository.py +465 -0
  34. backend/database/repositories/file_metadata_repository.py +463 -0
  35. backend/database/repositories/processed_file_repository.py +563 -0
  36. backend/jobs/__init__.py +0 -0
  37. backend/jobs/background_job_service.py +81 -0
  38. backend/jobs/event_bus.py +109 -0
  39. backend/jobs/event_models.py +42 -0
  40. backend/jobs/exceptions.py +4 -0
  41. backend/jobs/job.py +18 -0
  42. backend/jobs/job_store.py +148 -0
  43. backend/jobs/job_submission.py +7 -0
  44. backend/jobs/runner.py +102 -0
  45. backend/main.py +80 -0
  46. backend/pipeline/__init__.py +0 -0
  47. backend/pipeline/models.py +49 -0
  48. backend/pipeline/pipeline.py +353 -0
  49. backend/pipeline/scheduler.py +98 -0
  50. backend/services/__init__.py +0 -0
  51. backend/services/download_file_service.py +382 -0
  52. backend/services/metadata_service.py +397 -0
  53. backend/services/process_file_service.py +511 -0
  54. backend/services/slot_service.py +300 -0
  55. backend/util/__init__.py +0 -0
  56. backend/util/constants.py +73 -0
  57. backend/util/enums.py +115 -0
  58. backend/util/funcs.py +27 -0
  59. comet_hunter/__init__.py +1 -0
  60. comet_hunter/cli.py +133 -0
  61. comet_hunter/launcher.py +34 -0
  62. comet_hunter/process_store.py +35 -0
  63. comet_hunter-0.1.0.dist-info/METADATA +159 -0
  64. comet_hunter-0.1.0.dist-info/RECORD +98 -0
  65. comet_hunter-0.1.0.dist-info/WHEEL +5 -0
  66. comet_hunter-0.1.0.dist-info/entry_points.txt +2 -0
  67. comet_hunter-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. comet_hunter-0.1.0.dist-info/top_level.txt +3 -0
  69. frontend/__init__.py +0 -0
  70. frontend/app.py +90 -0
  71. frontend/components/__init__.py +0 -0
  72. frontend/components/active_slot_panel.py +47 -0
  73. frontend/components/event_stream_panel.py +143 -0
  74. frontend/components/footer.py +11 -0
  75. frontend/components/get_frames_panel.py +315 -0
  76. frontend/components/health_panel.py +29 -0
  77. frontend/components/image_panel.py +137 -0
  78. frontend/components/navbar.py +15 -0
  79. frontend/components/scheduler_panel.py +220 -0
  80. frontend/components/sync_frames_panel.py +437 -0
  81. frontend/components/sync_slots_panel.py +123 -0
  82. frontend/components/utc_clock.py +18 -0
  83. frontend/config.py +20 -0
  84. frontend/events/__init__.py +0 -0
  85. frontend/events/sse.py +129 -0
  86. frontend/models/__init__.py +0 -0
  87. frontend/models/frame.py +8 -0
  88. frontend/models/instruments.py +5 -0
  89. frontend/models/job.py +16 -0
  90. frontend/models/processed_file.py +8 -0
  91. frontend/models/slot.py +6 -0
  92. frontend/services/__init__.py +0 -0
  93. frontend/services/api.py +279 -0
  94. frontend/state/__init__.py +0 -0
  95. frontend/state/event_store.py +33 -0
  96. frontend/state/frame_store.py +86 -0
  97. frontend/styles/__init__.py +0 -0
  98. frontend/styles/theme.py +78 -0
backend/__init__.py ADDED
File without changes
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