pulse-engine 0.2.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 (150) hide show
  1. pulse_engine/__init__.py +0 -0
  2. pulse_engine/adapters/__init__.py +58 -0
  3. pulse_engine/adapters/audio_transcription.py +167 -0
  4. pulse_engine/adapters/batcher.py +36 -0
  5. pulse_engine/adapters/digital_news.py +128 -0
  6. pulse_engine/adapters/digital_news_metadata.py +536 -0
  7. pulse_engine/adapters/exceptions.py +10 -0
  8. pulse_engine/adapters/models.py +134 -0
  9. pulse_engine/adapters/opensearch_storage.py +160 -0
  10. pulse_engine/adapters/speech_content.py +130 -0
  11. pulse_engine/adapters/speech_metadata.py +374 -0
  12. pulse_engine/adapters/twitter.py +423 -0
  13. pulse_engine/adapters/youtube_downloader.py +186 -0
  14. pulse_engine/adapters/youtube_metadata.py +261 -0
  15. pulse_engine/api/__init__.py +0 -0
  16. pulse_engine/api/v1/__init__.py +0 -0
  17. pulse_engine/api/v1/auth.py +91 -0
  18. pulse_engine/api/v1/health.py +62 -0
  19. pulse_engine/api/v1/router.py +16 -0
  20. pulse_engine/chain_recovery.py +131 -0
  21. pulse_engine/cli/__init__.py +0 -0
  22. pulse_engine/cli/main.py +169 -0
  23. pulse_engine/cli/templates/cookiecutter.json +4 -0
  24. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
  25. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
  26. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
  27. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
  28. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
  29. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
  30. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
  31. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
  32. pulse_engine/client.py +95 -0
  33. pulse_engine/config.py +157 -0
  34. pulse_engine/core/__init__.py +0 -0
  35. pulse_engine/core/error_handlers.py +64 -0
  36. pulse_engine/core/exceptions.py +67 -0
  37. pulse_engine/core/job_token.py +109 -0
  38. pulse_engine/core/logging.py +45 -0
  39. pulse_engine/core/scope.py +23 -0
  40. pulse_engine/core/security.py +130 -0
  41. pulse_engine/database.py +30 -0
  42. pulse_engine/dependencies.py +166 -0
  43. pulse_engine/deployment/__init__.py +0 -0
  44. pulse_engine/deployment/backend_deployment_repository.py +83 -0
  45. pulse_engine/deployment/backends/__init__.py +0 -0
  46. pulse_engine/deployment/backends/base.py +50 -0
  47. pulse_engine/deployment/backends/exceptions.py +20 -0
  48. pulse_engine/deployment/backends/native_lambda.py +125 -0
  49. pulse_engine/deployment/backends/prefect_ecs.py +116 -0
  50. pulse_engine/deployment/backends/prefect_k8s.py +131 -0
  51. pulse_engine/deployment/backends/registry.py +50 -0
  52. pulse_engine/deployment/infra_provisioner.py +285 -0
  53. pulse_engine/deployment/job_launcher.py +178 -0
  54. pulse_engine/deployment/models.py +48 -0
  55. pulse_engine/deployment/repository.py +54 -0
  56. pulse_engine/deployment/router.py +22 -0
  57. pulse_engine/deployment/schemas.py +18 -0
  58. pulse_engine/deployment/service.py +65 -0
  59. pulse_engine/extractor/__init__.py +0 -0
  60. pulse_engine/extractor/adapters/__init__.py +0 -0
  61. pulse_engine/extractor/base.py +48 -0
  62. pulse_engine/extractor/models.py +50 -0
  63. pulse_engine/extractor/orchestrator/__init__.py +15 -0
  64. pulse_engine/extractor/orchestrator/base.py +34 -0
  65. pulse_engine/extractor/orchestrator/noop.py +37 -0
  66. pulse_engine/extractor/orchestrator/prefect.py +163 -0
  67. pulse_engine/extractor/repository.py +163 -0
  68. pulse_engine/extractor/router.py +102 -0
  69. pulse_engine/extractor/schemas.py +93 -0
  70. pulse_engine/extractor/service.py +431 -0
  71. pulse_engine/extractor/stage_models.py +36 -0
  72. pulse_engine/extractor/stage_repository.py +109 -0
  73. pulse_engine/main.py +195 -0
  74. pulse_engine/mcp/__init__.py +0 -0
  75. pulse_engine/mcp/__main__.py +5 -0
  76. pulse_engine/mcp/server.py +108 -0
  77. pulse_engine/mcp/tools_jobs.py +159 -0
  78. pulse_engine/mcp/tools_kb.py +88 -0
  79. pulse_engine/mcp/tools_modules.py +115 -0
  80. pulse_engine/mcp/tools_pipelines.py +215 -0
  81. pulse_engine/mcp/tools_processor.py +208 -0
  82. pulse_engine/middleware/__init__.py +0 -0
  83. pulse_engine/middleware/rate_limit.py +144 -0
  84. pulse_engine/middleware/request_id.py +16 -0
  85. pulse_engine/middleware/security_headers.py +25 -0
  86. pulse_engine/middleware/tenant.py +90 -0
  87. pulse_engine/pipeline/__init__.py +0 -0
  88. pulse_engine/pipeline/config_parser.py +148 -0
  89. pulse_engine/pipeline/expression.py +268 -0
  90. pulse_engine/pipeline/models.py +98 -0
  91. pulse_engine/pipeline/repositories.py +224 -0
  92. pulse_engine/pipeline/router_modules.py +66 -0
  93. pulse_engine/pipeline/router_pipelines.py +198 -0
  94. pulse_engine/pipeline/schemas.py +200 -0
  95. pulse_engine/pipeline/service.py +250 -0
  96. pulse_engine/pipeline/translators/__init__.py +44 -0
  97. pulse_engine/pipeline/translators/airflow_status.py +11 -0
  98. pulse_engine/pipeline/translators/airflow_translator.py +22 -0
  99. pulse_engine/pipeline/translators/base.py +42 -0
  100. pulse_engine/pipeline/translators/prefect_status.py +93 -0
  101. pulse_engine/pipeline/translators/prefect_translator.py +195 -0
  102. pulse_engine/processor/__init__.py +0 -0
  103. pulse_engine/processor/base.py +36 -0
  104. pulse_engine/processor/core/__init__.py +0 -0
  105. pulse_engine/processor/core/analysis.py +148 -0
  106. pulse_engine/processor/core/chunking.py +158 -0
  107. pulse_engine/processor/core/prompts.py +340 -0
  108. pulse_engine/processor/core/topic_splitter.py +105 -0
  109. pulse_engine/processor/defaults/__init__.py +11 -0
  110. pulse_engine/processor/defaults/core_processor.py +12 -0
  111. pulse_engine/processor/defaults/postprocessor.py +12 -0
  112. pulse_engine/processor/defaults/preprocessor.py +12 -0
  113. pulse_engine/processor/llm/__init__.py +0 -0
  114. pulse_engine/processor/llm/provider.py +58 -0
  115. pulse_engine/processor/ocr/gemini.py +52 -0
  116. pulse_engine/processor/pipeline.py +107 -0
  117. pulse_engine/processor/postprocessor/__init__.py +0 -0
  118. pulse_engine/processor/postprocessor/embeddings.py +34 -0
  119. pulse_engine/processor/postprocessor/tasks.py +180 -0
  120. pulse_engine/processor/preprocessor/__init__.py +0 -0
  121. pulse_engine/processor/preprocessor/tasks.py +71 -0
  122. pulse_engine/processor/router.py +192 -0
  123. pulse_engine/processor/schemas.py +167 -0
  124. pulse_engine/registry.py +117 -0
  125. pulse_engine/runners/__init__.py +0 -0
  126. pulse_engine/runners/lambda_runner.py +26 -0
  127. pulse_engine/runners/pipeline_runner.py +43 -0
  128. pulse_engine/runners/prefect_pipeline_flow.py +904 -0
  129. pulse_engine/runners/prefect_runner.py +33 -0
  130. pulse_engine/s3.py +72 -0
  131. pulse_engine/secrets.py +46 -0
  132. pulse_engine/services/__init__.py +0 -0
  133. pulse_engine/services/bootstrap.py +211 -0
  134. pulse_engine/services/opensearch.py +84 -0
  135. pulse_engine/storage/__init__.py +0 -0
  136. pulse_engine/storage/connectors/__init__.py +0 -0
  137. pulse_engine/storage/connectors/athena.py +226 -0
  138. pulse_engine/storage/connectors/base.py +32 -0
  139. pulse_engine/storage/connectors/opensearch.py +344 -0
  140. pulse_engine/storage/knowledge_base.py +68 -0
  141. pulse_engine/storage/router.py +78 -0
  142. pulse_engine/storage/schemas.py +93 -0
  143. pulse_engine/testing/__init__.py +13 -0
  144. pulse_engine/testing/fixtures.py +50 -0
  145. pulse_engine/testing/mocks.py +104 -0
  146. pulse_engine/worker.py +53 -0
  147. pulse_engine-0.2.0.dist-info/METADATA +654 -0
  148. pulse_engine-0.2.0.dist-info/RECORD +150 -0
  149. pulse_engine-0.2.0.dist-info/WHEEL +4 -0
  150. pulse_engine-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,93 @@
1
+ """Pydantic request/response schemas for the Jobs API."""
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class JobStatus(str, Enum):
11
+ pending = "pending"
12
+ running = "running"
13
+ completed = "completed"
14
+ failed = "failed"
15
+ cancelled = "cancelled"
16
+
17
+
18
+ class JobPriority(str, Enum):
19
+ low = "low"
20
+ normal = "normal"
21
+ high = "high"
22
+
23
+
24
+ class CreateJobRequest(BaseModel):
25
+ product: str
26
+ stage: Literal["extractor", "processor", "storage"] = "extractor"
27
+ orchestrator: str = "prefect"
28
+ compute: str = "ecs"
29
+ chain: bool = True
30
+ job_id: str | None = None # reuse existing job for isolation runs
31
+ config: dict[str, Any] = Field(default_factory=dict)
32
+ # Retained for backwards compatibility:
33
+ job_type: str | None = None
34
+ priority: JobPriority = JobPriority.normal
35
+ orchestrator_run_id: str | None = None
36
+ parameters: dict[str, Any] = Field(default_factory=dict)
37
+ callback_url: str | None = None
38
+
39
+
40
+ class CreateJobResponse(BaseModel):
41
+ job_id: str
42
+ job_type: str
43
+ product: str
44
+ stage: str
45
+ status: str
46
+ created_at: datetime
47
+
48
+
49
+ class StatusUpdateRequest(BaseModel):
50
+ status: JobStatus
51
+ stage: str | None = None
52
+ result_summary: dict[str, Any] | None = None
53
+ error: str | None = None
54
+
55
+
56
+ class StatusUpdateResponse(BaseModel):
57
+ job_id: str
58
+ status: str
59
+ updated_at: datetime
60
+
61
+
62
+ class JobStageResponse(BaseModel):
63
+ stage: str
64
+ status: str
65
+ prefect_flow_run_id: str | None = None
66
+ started_at: datetime | None = None
67
+ completed_at: datetime | None = None
68
+ error: str | None = None
69
+
70
+
71
+ class JobResponse(BaseModel):
72
+ job_id: str
73
+ job_type: str
74
+ product: str
75
+ tenant_id: str
76
+ status: str
77
+ priority: str
78
+ parameters: dict[str, Any]
79
+ orchestrator_run_id: str | None = None
80
+ callback_url: str | None = None
81
+ created_at: datetime
82
+ started_at: datetime | None = None
83
+ completed_at: datetime | None = None
84
+ result_summary: dict[str, Any] | None = None
85
+ error: str | None = None
86
+ stages: list[JobStageResponse] = Field(default_factory=list)
87
+
88
+
89
+ class JobListResponse(BaseModel):
90
+ total: int
91
+ jobs: list[JobResponse]
92
+ limit: int
93
+ offset: int
@@ -0,0 +1,431 @@
1
+ """Job service — business logic for job management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+ import structlog
10
+
11
+ from pulse_engine.config import Settings
12
+ from pulse_engine.core.exceptions import NotFoundError, TooManyRequestsError
13
+ from pulse_engine.extractor.schemas import (
14
+ CreateJobRequest,
15
+ CreateJobResponse,
16
+ JobListResponse,
17
+ JobResponse,
18
+ JobStageResponse,
19
+ JobStatus,
20
+ StatusUpdateRequest,
21
+ StatusUpdateResponse,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from pulse_engine.core.job_token import JobTokenIssuer
26
+ from pulse_engine.deployment.repository import RegistrationRepository
27
+ from pulse_engine.extractor.orchestrator.base import BaseOrchestratorAdapter
28
+ from pulse_engine.extractor.repository import JobRepository
29
+ from pulse_engine.extractor.stage_repository import StageRepository
30
+
31
+ logger = structlog.get_logger(__name__)
32
+
33
+ TERMINAL_STATES = {JobStatus.completed, JobStatus.failed, JobStatus.cancelled}
34
+
35
+ _STAGE_MAP = {
36
+ "extractor": "extraction",
37
+ "processor": "processing",
38
+ "storage": "storage",
39
+ }
40
+ _STAGE_ORDER = ["extraction", "processing", "storage"]
41
+
42
+
43
+ class JobService:
44
+ def __init__(
45
+ self,
46
+ repository: JobRepository,
47
+ orchestrator: BaseOrchestratorAdapter,
48
+ settings: Settings,
49
+ stage_repository: StageRepository | None = None,
50
+ deployment_repository: RegistrationRepository | None = None,
51
+ token_issuer: JobTokenIssuer | None = None,
52
+ job_launcher: Any | None = None, # JobLauncher | None
53
+ ) -> None:
54
+ self._repository = repository
55
+ self._orchestrator = orchestrator
56
+ self._settings = settings
57
+ self._stage_repo = stage_repository
58
+ self._deploy_repo = deployment_repository # kept for legacy path
59
+ self._token_issuer = token_issuer # kept for legacy path
60
+ self._job_launcher = job_launcher # preferred path
61
+
62
+ async def register_job(
63
+ self, tenant_id: str, request: CreateJobRequest
64
+ ) -> CreateJobResponse:
65
+ active = await self._repository.count_active(tenant_id)
66
+ if active >= self._settings.pulse_max_concurrent_jobs_per_tenant:
67
+ raise TooManyRequestsError(
68
+ "Concurrent job limit reached for this tenant",
69
+ tenant_id=tenant_id,
70
+ limit=self._settings.pulse_max_concurrent_jobs_per_tenant,
71
+ )
72
+
73
+ job_type = request.job_type or _STAGE_MAP.get(request.stage, request.stage)
74
+
75
+ if request.job_id:
76
+ record = await self._repository.get(request.job_id, tenant_id)
77
+ if record is None:
78
+ raise NotFoundError("Job not found", job_id=request.job_id)
79
+ else:
80
+ params = {
81
+ **request.parameters,
82
+ **request.config,
83
+ "chain": request.chain,
84
+ }
85
+ record = await self._repository.create(
86
+ tenant_id=tenant_id,
87
+ job_type=job_type,
88
+ product=request.product,
89
+ priority=request.priority.value,
90
+ parameters=params,
91
+ orchestrator_run_id=request.orchestrator_run_id,
92
+ callback_url=request.callback_url,
93
+ )
94
+
95
+ stage_name = _STAGE_MAP.get(request.stage, request.stage)
96
+ trigger_condition = request.stage is not None and (
97
+ self._job_launcher is not None
98
+ or (self._stage_repo and self._deploy_repo and self._token_issuer)
99
+ )
100
+ if trigger_condition:
101
+ await self._trigger_stage(
102
+ job_id=record.job_id,
103
+ tenant_id=tenant_id,
104
+ product=request.product,
105
+ stage=request.stage,
106
+ stage_name=stage_name,
107
+ chain=request.chain,
108
+ config=request.config,
109
+ orchestrator=request.orchestrator,
110
+ compute=request.compute,
111
+ )
112
+
113
+ if record.status == "pending":
114
+ await self._repository.update_status(
115
+ record.job_id,
116
+ tenant_id,
117
+ "running",
118
+ started_at=datetime.now(UTC),
119
+ )
120
+
121
+ return CreateJobResponse(
122
+ job_id=record.job_id,
123
+ job_type=record.job_type,
124
+ product=record.product,
125
+ stage=stage_name,
126
+ status="running",
127
+ created_at=record.created_at,
128
+ )
129
+
130
+ async def _trigger_stage(
131
+ self,
132
+ job_id: str,
133
+ tenant_id: str,
134
+ product: str,
135
+ stage: str,
136
+ stage_name: str,
137
+ chain: bool,
138
+ config: dict[str, Any],
139
+ orchestrator: str = "prefect",
140
+ compute: str = "ecs",
141
+ ) -> None:
142
+ if self._job_launcher is not None:
143
+ from pulse_engine.deployment.job_launcher import LaunchResult
144
+
145
+ result: LaunchResult = await self._job_launcher.launch(
146
+ job_id=job_id,
147
+ tenant_id=tenant_id,
148
+ product=product,
149
+ stage=stage,
150
+ orchestrator=orchestrator,
151
+ compute=compute,
152
+ chain=chain,
153
+ config=config,
154
+ )
155
+ if self._stage_repo:
156
+ await self._stage_repo.create(
157
+ job_id=job_id,
158
+ stage=stage_name,
159
+ prefect_flow_run_id=result.flow_run_id,
160
+ )
161
+ logger.info(
162
+ "stage_triggered",
163
+ job_id=job_id,
164
+ stage=stage_name,
165
+ flow_run_id=result.flow_run_id,
166
+ )
167
+ return
168
+
169
+ # Legacy path: direct orchestrator call (backwards compat when launcher absent)
170
+ if self._deploy_repo is None or self._token_issuer is None:
171
+ return
172
+ deployment = await self._deploy_repo.get(
173
+ product=product,
174
+ stage=stage,
175
+ )
176
+ if deployment is None:
177
+ raise NotFoundError(
178
+ f"No deployment registered for {product}/{stage}",
179
+ product=product,
180
+ stage=stage,
181
+ )
182
+
183
+ scope = ["jobs:status"]
184
+ if stage == "storage":
185
+ scope.append("kb:write")
186
+ if chain:
187
+ scope.append("jobs:trigger_next")
188
+
189
+ token = self._token_issuer.issue(
190
+ job_id=job_id,
191
+ tenant_id=tenant_id,
192
+ product=product,
193
+ stage=stage_name,
194
+ scope=scope,
195
+ )
196
+
197
+ flow_run_id = await self._orchestrator.create_flow_run(
198
+ deployment_id=deployment.prefect_deployment_id, # type: ignore[attr-defined]
199
+ parameters={
200
+ "job_id": job_id,
201
+ "chain": chain,
202
+ "config": config,
203
+ "pulse_api_token": token,
204
+ "pulse_engine_url": self._settings.pulse_engine_url,
205
+ },
206
+ )
207
+
208
+ if self._stage_repo:
209
+ await self._stage_repo.create(
210
+ job_id=job_id,
211
+ stage=stage_name,
212
+ prefect_flow_run_id=flow_run_id,
213
+ )
214
+
215
+ logger.info(
216
+ "stage_triggered",
217
+ job_id=job_id,
218
+ stage=stage_name,
219
+ flow_run_id=flow_run_id,
220
+ )
221
+
222
+ async def get_job(self, tenant_id: str, job_id: str) -> JobResponse:
223
+ record = await self._repository.get(job_id, tenant_id)
224
+ if record is None:
225
+ raise NotFoundError("Job not found", job_id=job_id)
226
+
227
+ if record.orchestrator_run_id and record.status in (
228
+ "pending",
229
+ "running",
230
+ ):
231
+ try:
232
+ run_status = await self._orchestrator.get_run_status(
233
+ record.orchestrator_run_id
234
+ )
235
+ if (
236
+ run_status.status != "unknown"
237
+ and run_status.status != record.status
238
+ ):
239
+ record = await self._repository.update_status(
240
+ job_id, tenant_id, run_status.status
241
+ )
242
+ except Exception:
243
+ logger.warning(
244
+ "orchestrator_sync_failed",
245
+ job_id=job_id,
246
+ exc_info=True,
247
+ )
248
+
249
+ response = self._to_response(record)
250
+
251
+ if self._stage_repo:
252
+ stages = await self._stage_repo.get_stages_for_job(job_id)
253
+ response.stages = [
254
+ JobStageResponse(
255
+ stage=s.stage,
256
+ status=s.status,
257
+ prefect_flow_run_id=s.prefect_flow_run_id,
258
+ started_at=s.started_at,
259
+ completed_at=s.completed_at,
260
+ error=s.error,
261
+ )
262
+ for s in stages
263
+ ]
264
+
265
+ return response
266
+
267
+ async def list_jobs(
268
+ self,
269
+ tenant_id: str,
270
+ status: str | None = None,
271
+ product: str | None = None,
272
+ job_type: str | None = None,
273
+ limit: int = 20,
274
+ offset: int = 0,
275
+ sort_by: str = "created_at",
276
+ order: str = "desc",
277
+ ) -> JobListResponse:
278
+ jobs, total = await self._repository.list_jobs(
279
+ tenant_id=tenant_id,
280
+ status=status,
281
+ product=product,
282
+ job_type=job_type,
283
+ limit=limit,
284
+ offset=offset,
285
+ sort_by=sort_by,
286
+ order=order,
287
+ )
288
+ return JobListResponse(
289
+ total=total,
290
+ jobs=[self._to_response(j) for j in jobs],
291
+ limit=limit,
292
+ offset=offset,
293
+ )
294
+
295
+ async def push_status(
296
+ self, tenant_id: str, job_id: str, request: StatusUpdateRequest
297
+ ) -> StatusUpdateResponse:
298
+ now = datetime.now(UTC)
299
+ extra_fields: dict[str, object] = {}
300
+
301
+ if request.status == JobStatus.running:
302
+ extra_fields["started_at"] = now
303
+ if request.status in TERMINAL_STATES:
304
+ extra_fields["completed_at"] = now
305
+ if request.result_summary is not None:
306
+ extra_fields["result_summary"] = request.result_summary
307
+ if request.error is not None:
308
+ extra_fields["error"] = request.error
309
+
310
+ if request.stage and self._stage_repo:
311
+ stages = await self._stage_repo.get_stages_for_job(job_id)
312
+ for s in stages:
313
+ if s.stage == request.stage:
314
+ await self._stage_repo.update_status(
315
+ s.id,
316
+ request.status.value,
317
+ error=request.error,
318
+ )
319
+ break
320
+
321
+ if request.status in TERMINAL_STATES:
322
+ if request.status == JobStatus.completed and request.stage == "storage":
323
+ extra_fields["status"] = "completed"
324
+ elif request.status == JobStatus.failed:
325
+ extra_fields["status"] = "failed"
326
+
327
+ record = await self._repository.update_status(
328
+ job_id, tenant_id, request.status.value, **extra_fields
329
+ )
330
+ if record is None:
331
+ raise NotFoundError("Job not found", job_id=job_id)
332
+
333
+ if request.status in TERMINAL_STATES and record.callback_url:
334
+ await self._fire_callback(
335
+ record.callback_url,
336
+ {
337
+ "job_id": record.job_id,
338
+ "status": record.status,
339
+ "result_summary": record.result_summary,
340
+ "error": record.error,
341
+ },
342
+ )
343
+
344
+ return StatusUpdateResponse(
345
+ job_id=record.job_id,
346
+ status=record.status,
347
+ updated_at=now,
348
+ )
349
+
350
+ async def cancel_job(self, tenant_id: str, job_id: str) -> JobResponse:
351
+ record = await self._repository.get(job_id, tenant_id)
352
+ if record is None:
353
+ raise NotFoundError("Job not found", job_id=job_id)
354
+
355
+ if record.orchestrator_run_id:
356
+ try:
357
+ await self._orchestrator.cancel_run(
358
+ record.orchestrator_run_id,
359
+ )
360
+ except Exception:
361
+ logger.warning(
362
+ "orchestrator_cancel_failed",
363
+ job_id=job_id,
364
+ exc_info=True,
365
+ )
366
+
367
+ now = datetime.now(UTC)
368
+ record = await self._repository.update_status(
369
+ job_id, tenant_id, "cancelled", completed_at=now
370
+ )
371
+
372
+ if record and record.callback_url:
373
+ await self._fire_callback(
374
+ record.callback_url,
375
+ {
376
+ "job_id": record.job_id,
377
+ "status": record.status,
378
+ "result_summary": record.result_summary,
379
+ "error": record.error,
380
+ },
381
+ )
382
+
383
+ return self._to_response(record)
384
+
385
+ async def delete_job(self, tenant_id: str, job_id: str) -> bool:
386
+ deleted = await self._repository.delete(job_id, tenant_id)
387
+ if not deleted:
388
+ raise NotFoundError("Job not found", job_id=job_id)
389
+ return True
390
+
391
+ async def _fire_callback(self, callback_url: str, payload: dict[str, Any]) -> None:
392
+ try:
393
+ async with httpx.AsyncClient(
394
+ timeout=self._settings.pulse_job_callback_timeout
395
+ ) as client:
396
+ resp = await client.post(callback_url, json=payload)
397
+ logger.info(
398
+ "callback_fired",
399
+ url=callback_url,
400
+ status_code=resp.status_code,
401
+ job_id=payload.get("job_id"),
402
+ )
403
+ except Exception:
404
+ logger.warning(
405
+ "callback_failed",
406
+ url=callback_url,
407
+ job_id=payload.get("job_id"),
408
+ exc_info=True,
409
+ )
410
+
411
+ @staticmethod
412
+ def _to_response(record: object) -> JobResponse:
413
+ from pulse_engine.extractor.models import JobRecordModel
414
+
415
+ r: JobRecordModel = record # type: ignore[assignment]
416
+ return JobResponse(
417
+ job_id=r.job_id,
418
+ job_type=r.job_type,
419
+ product=r.product,
420
+ tenant_id=r.tenant_id,
421
+ status=r.status,
422
+ priority=r.priority,
423
+ parameters=r.parameters,
424
+ orchestrator_run_id=r.orchestrator_run_id,
425
+ callback_url=r.callback_url,
426
+ created_at=r.created_at,
427
+ started_at=r.started_at,
428
+ completed_at=r.completed_at,
429
+ result_summary=r.result_summary,
430
+ error=r.error,
431
+ )
@@ -0,0 +1,36 @@
1
+ """Per-stage tracking model for pipeline jobs."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ import sqlalchemy as sa
7
+ from sqlalchemy.orm import Mapped, mapped_column
8
+
9
+ from pulse_engine.database import Base
10
+
11
+
12
+ class JobStageModel(Base):
13
+ __tablename__ = "job_stages"
14
+
15
+ id: Mapped[str] = mapped_column(
16
+ sa.String, primary_key=True, default=lambda: str(uuid.uuid4())
17
+ )
18
+ job_id: Mapped[str] = mapped_column(
19
+ sa.String, sa.ForeignKey("job_records.job_id"), nullable=False, index=True
20
+ )
21
+ stage: Mapped[str] = mapped_column(sa.String(50), nullable=False)
22
+ status: Mapped[str] = mapped_column(
23
+ sa.String(50), nullable=False, default="pending"
24
+ )
25
+ prefect_flow_run_id: Mapped[str | None] = mapped_column(
26
+ sa.String(255), nullable=True, default=None
27
+ )
28
+ started_at: Mapped[datetime | None] = mapped_column(
29
+ sa.DateTime, nullable=True, default=None
30
+ )
31
+ completed_at: Mapped[datetime | None] = mapped_column(
32
+ sa.DateTime, nullable=True, default=None
33
+ )
34
+ error: Mapped[str | None] = mapped_column(sa.Text, nullable=True, default=None)
35
+
36
+ __table_args__ = (sa.Index("ix_job_stages_job_id_stage", "job_id", "stage"),)
@@ -0,0 +1,109 @@
1
+ """Data access layer for job stage records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+
8
+ import sqlalchemy as sa
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from pulse_engine.extractor.stage_models import JobStageModel
12
+
13
+
14
+ class StageRepository:
15
+ def __init__(self, session: AsyncSession) -> None:
16
+ self._session = session
17
+
18
+ async def create(
19
+ self,
20
+ job_id: str,
21
+ stage: str,
22
+ prefect_flow_run_id: str | None = None,
23
+ ) -> JobStageModel:
24
+ record = JobStageModel(
25
+ id=str(uuid.uuid4()),
26
+ job_id=job_id,
27
+ stage=stage,
28
+ status="pending",
29
+ prefect_flow_run_id=prefect_flow_run_id,
30
+ )
31
+ self._session.add(record)
32
+ await self._session.commit()
33
+ await self._session.refresh(record)
34
+ return record
35
+
36
+ async def update_status(
37
+ self,
38
+ stage_id: str,
39
+ status: str,
40
+ error: str | None = None,
41
+ ) -> JobStageModel:
42
+ stmt = sa.select(JobStageModel).where(JobStageModel.id == stage_id)
43
+ result = await self._session.execute(stmt)
44
+ record = result.scalar_one()
45
+
46
+ now = datetime.now(UTC)
47
+ record.status = status
48
+ if status == "running" and record.started_at is None:
49
+ record.started_at = now
50
+ if status in ("completed", "failed", "cancelled"):
51
+ record.completed_at = now
52
+ if error is not None:
53
+ record.error = error
54
+
55
+ await self._session.commit()
56
+ await self._session.refresh(record)
57
+ return record
58
+
59
+ async def get_stages_for_job(self, job_id: str) -> list[JobStageModel]:
60
+ stmt = (
61
+ sa.select(JobStageModel)
62
+ .where(JobStageModel.job_id == job_id)
63
+ .order_by(JobStageModel.started_at.asc().nulls_last())
64
+ )
65
+ result = await self._session.execute(stmt)
66
+ return list(result.scalars().all())
67
+
68
+ async def get_by_flow_run_id(self, flow_run_id: str) -> JobStageModel | None:
69
+ stmt = sa.select(JobStageModel).where(
70
+ JobStageModel.prefect_flow_run_id == flow_run_id
71
+ )
72
+ result = await self._session.execute(stmt)
73
+ return result.scalar_one_or_none()
74
+
75
+ async def get_running_stages(self) -> list[JobStageModel]:
76
+ stmt = sa.select(JobStageModel).where(
77
+ JobStageModel.status == "running",
78
+ JobStageModel.prefect_flow_run_id.isnot(None),
79
+ )
80
+ result = await self._session.execute(stmt)
81
+ return list(result.scalars().all())
82
+
83
+ async def get_completed_unchained_stages(self) -> list[JobStageModel]:
84
+ """Get completed extraction/processing stages where the next stage
85
+ has NOT been triggered yet (no row exists for the downstream stage)."""
86
+ from sqlalchemy.orm import aliased
87
+
88
+ NextStage = aliased(JobStageModel)
89
+ _NEXT = {"extraction": "processing", "processing": "storage"}
90
+
91
+ results: list[JobStageModel] = []
92
+ for current, downstream in _NEXT.items():
93
+ next_exists = (
94
+ sa.select(sa.literal(1))
95
+ .where(
96
+ NextStage.job_id == JobStageModel.job_id,
97
+ NextStage.stage == downstream,
98
+ )
99
+ .correlate(JobStageModel)
100
+ .exists()
101
+ )
102
+ stmt = sa.select(JobStageModel).where(
103
+ JobStageModel.status == "completed",
104
+ JobStageModel.stage == current,
105
+ ~next_exists,
106
+ )
107
+ result = await self._session.execute(stmt)
108
+ results.extend(result.scalars().all())
109
+ return results