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.
- pulse_engine/__init__.py +0 -0
- pulse_engine/adapters/__init__.py +58 -0
- pulse_engine/adapters/audio_transcription.py +167 -0
- pulse_engine/adapters/batcher.py +36 -0
- pulse_engine/adapters/digital_news.py +128 -0
- pulse_engine/adapters/digital_news_metadata.py +536 -0
- pulse_engine/adapters/exceptions.py +10 -0
- pulse_engine/adapters/models.py +134 -0
- pulse_engine/adapters/opensearch_storage.py +160 -0
- pulse_engine/adapters/speech_content.py +130 -0
- pulse_engine/adapters/speech_metadata.py +374 -0
- pulse_engine/adapters/twitter.py +423 -0
- pulse_engine/adapters/youtube_downloader.py +186 -0
- pulse_engine/adapters/youtube_metadata.py +261 -0
- pulse_engine/api/__init__.py +0 -0
- pulse_engine/api/v1/__init__.py +0 -0
- pulse_engine/api/v1/auth.py +91 -0
- pulse_engine/api/v1/health.py +62 -0
- pulse_engine/api/v1/router.py +16 -0
- pulse_engine/chain_recovery.py +131 -0
- pulse_engine/cli/__init__.py +0 -0
- pulse_engine/cli/main.py +169 -0
- pulse_engine/cli/templates/cookiecutter.json +4 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
- pulse_engine/client.py +95 -0
- pulse_engine/config.py +157 -0
- pulse_engine/core/__init__.py +0 -0
- pulse_engine/core/error_handlers.py +64 -0
- pulse_engine/core/exceptions.py +67 -0
- pulse_engine/core/job_token.py +109 -0
- pulse_engine/core/logging.py +45 -0
- pulse_engine/core/scope.py +23 -0
- pulse_engine/core/security.py +130 -0
- pulse_engine/database.py +30 -0
- pulse_engine/dependencies.py +166 -0
- pulse_engine/deployment/__init__.py +0 -0
- pulse_engine/deployment/backend_deployment_repository.py +83 -0
- pulse_engine/deployment/backends/__init__.py +0 -0
- pulse_engine/deployment/backends/base.py +50 -0
- pulse_engine/deployment/backends/exceptions.py +20 -0
- pulse_engine/deployment/backends/native_lambda.py +125 -0
- pulse_engine/deployment/backends/prefect_ecs.py +116 -0
- pulse_engine/deployment/backends/prefect_k8s.py +131 -0
- pulse_engine/deployment/backends/registry.py +50 -0
- pulse_engine/deployment/infra_provisioner.py +285 -0
- pulse_engine/deployment/job_launcher.py +178 -0
- pulse_engine/deployment/models.py +48 -0
- pulse_engine/deployment/repository.py +54 -0
- pulse_engine/deployment/router.py +22 -0
- pulse_engine/deployment/schemas.py +18 -0
- pulse_engine/deployment/service.py +65 -0
- pulse_engine/extractor/__init__.py +0 -0
- pulse_engine/extractor/adapters/__init__.py +0 -0
- pulse_engine/extractor/base.py +48 -0
- pulse_engine/extractor/models.py +50 -0
- pulse_engine/extractor/orchestrator/__init__.py +15 -0
- pulse_engine/extractor/orchestrator/base.py +34 -0
- pulse_engine/extractor/orchestrator/noop.py +37 -0
- pulse_engine/extractor/orchestrator/prefect.py +163 -0
- pulse_engine/extractor/repository.py +163 -0
- pulse_engine/extractor/router.py +102 -0
- pulse_engine/extractor/schemas.py +93 -0
- pulse_engine/extractor/service.py +431 -0
- pulse_engine/extractor/stage_models.py +36 -0
- pulse_engine/extractor/stage_repository.py +109 -0
- pulse_engine/main.py +195 -0
- pulse_engine/mcp/__init__.py +0 -0
- pulse_engine/mcp/__main__.py +5 -0
- pulse_engine/mcp/server.py +108 -0
- pulse_engine/mcp/tools_jobs.py +159 -0
- pulse_engine/mcp/tools_kb.py +88 -0
- pulse_engine/mcp/tools_modules.py +115 -0
- pulse_engine/mcp/tools_pipelines.py +215 -0
- pulse_engine/mcp/tools_processor.py +208 -0
- pulse_engine/middleware/__init__.py +0 -0
- pulse_engine/middleware/rate_limit.py +144 -0
- pulse_engine/middleware/request_id.py +16 -0
- pulse_engine/middleware/security_headers.py +25 -0
- pulse_engine/middleware/tenant.py +90 -0
- pulse_engine/pipeline/__init__.py +0 -0
- pulse_engine/pipeline/config_parser.py +148 -0
- pulse_engine/pipeline/expression.py +268 -0
- pulse_engine/pipeline/models.py +98 -0
- pulse_engine/pipeline/repositories.py +224 -0
- pulse_engine/pipeline/router_modules.py +66 -0
- pulse_engine/pipeline/router_pipelines.py +198 -0
- pulse_engine/pipeline/schemas.py +200 -0
- pulse_engine/pipeline/service.py +250 -0
- pulse_engine/pipeline/translators/__init__.py +44 -0
- pulse_engine/pipeline/translators/airflow_status.py +11 -0
- pulse_engine/pipeline/translators/airflow_translator.py +22 -0
- pulse_engine/pipeline/translators/base.py +42 -0
- pulse_engine/pipeline/translators/prefect_status.py +93 -0
- pulse_engine/pipeline/translators/prefect_translator.py +195 -0
- pulse_engine/processor/__init__.py +0 -0
- pulse_engine/processor/base.py +36 -0
- pulse_engine/processor/core/__init__.py +0 -0
- pulse_engine/processor/core/analysis.py +148 -0
- pulse_engine/processor/core/chunking.py +158 -0
- pulse_engine/processor/core/prompts.py +340 -0
- pulse_engine/processor/core/topic_splitter.py +105 -0
- pulse_engine/processor/defaults/__init__.py +11 -0
- pulse_engine/processor/defaults/core_processor.py +12 -0
- pulse_engine/processor/defaults/postprocessor.py +12 -0
- pulse_engine/processor/defaults/preprocessor.py +12 -0
- pulse_engine/processor/llm/__init__.py +0 -0
- pulse_engine/processor/llm/provider.py +58 -0
- pulse_engine/processor/ocr/gemini.py +52 -0
- pulse_engine/processor/pipeline.py +107 -0
- pulse_engine/processor/postprocessor/__init__.py +0 -0
- pulse_engine/processor/postprocessor/embeddings.py +34 -0
- pulse_engine/processor/postprocessor/tasks.py +180 -0
- pulse_engine/processor/preprocessor/__init__.py +0 -0
- pulse_engine/processor/preprocessor/tasks.py +71 -0
- pulse_engine/processor/router.py +192 -0
- pulse_engine/processor/schemas.py +167 -0
- pulse_engine/registry.py +117 -0
- pulse_engine/runners/__init__.py +0 -0
- pulse_engine/runners/lambda_runner.py +26 -0
- pulse_engine/runners/pipeline_runner.py +43 -0
- pulse_engine/runners/prefect_pipeline_flow.py +904 -0
- pulse_engine/runners/prefect_runner.py +33 -0
- pulse_engine/s3.py +72 -0
- pulse_engine/secrets.py +46 -0
- pulse_engine/services/__init__.py +0 -0
- pulse_engine/services/bootstrap.py +211 -0
- pulse_engine/services/opensearch.py +84 -0
- pulse_engine/storage/__init__.py +0 -0
- pulse_engine/storage/connectors/__init__.py +0 -0
- pulse_engine/storage/connectors/athena.py +226 -0
- pulse_engine/storage/connectors/base.py +32 -0
- pulse_engine/storage/connectors/opensearch.py +344 -0
- pulse_engine/storage/knowledge_base.py +68 -0
- pulse_engine/storage/router.py +78 -0
- pulse_engine/storage/schemas.py +93 -0
- pulse_engine/testing/__init__.py +13 -0
- pulse_engine/testing/fixtures.py +50 -0
- pulse_engine/testing/mocks.py +104 -0
- pulse_engine/worker.py +53 -0
- pulse_engine-0.2.0.dist-info/METADATA +654 -0
- pulse_engine-0.2.0.dist-info/RECORD +150 -0
- pulse_engine-0.2.0.dist-info/WHEEL +4 -0
- 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
|