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,904 @@
|
|
|
1
|
+
"""Prefect pipeline flow — the generic @flow that interprets DAG parameters.
|
|
2
|
+
|
|
3
|
+
This is the ONLY Prefect flow for the new pipeline orchestration system.
|
|
4
|
+
The PrefectTranslator submits flow runs that execute this flow. It reads
|
|
5
|
+
the serialized DAG from flow parameters, creates @task invocations for
|
|
6
|
+
each step, handles fan-out/fan-in, and dispatches containers via the
|
|
7
|
+
appropriate compute backend.
|
|
8
|
+
|
|
9
|
+
Entrypoint: pulse_engine.runners.prefect_pipeline_flow:pipeline_flow
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from pulse_engine.secrets import fetch_secret
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Prefect imports are deferred so that pure helper functions
|
|
26
|
+
# (_build_execution_order, _resolve_module_env)
|
|
27
|
+
# can be tested without installing Prefect.
|
|
28
|
+
try:
|
|
29
|
+
from prefect import flow as _prefect_flow
|
|
30
|
+
from prefect import task as _prefect_task
|
|
31
|
+
from prefect.futures import PrefectFuture # noqa: F401
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
_prefect_flow = None
|
|
34
|
+
_prefect_task = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Infrastructure provisioner (singleton per process)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_provisioner = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_provisioner() -> Any:
|
|
45
|
+
"""Lazy-load the InfraProvisioner from engine settings.
|
|
46
|
+
|
|
47
|
+
Returns None if pipeline infrastructure settings are not configured
|
|
48
|
+
(e.g. local dev without AWS).
|
|
49
|
+
"""
|
|
50
|
+
global _provisioner
|
|
51
|
+
if _provisioner is not None:
|
|
52
|
+
return _provisioner
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from pulse_engine.config import get_settings
|
|
56
|
+
from pulse_engine.deployment.infra_provisioner import InfraProvisioner
|
|
57
|
+
|
|
58
|
+
settings = get_settings()
|
|
59
|
+
if not settings.pipeline_cluster_name:
|
|
60
|
+
logger.warning(
|
|
61
|
+
"Pipeline infrastructure settings not configured — "
|
|
62
|
+
"dispatching will use defaults (may fail without pre-provisioned infra)"
|
|
63
|
+
)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
_provisioner = InfraProvisioner(
|
|
67
|
+
region=settings.aws_region,
|
|
68
|
+
pipeline_cluster_name=settings.pipeline_cluster_name,
|
|
69
|
+
pipeline_execution_role_arn=settings.pipeline_execution_role_arn,
|
|
70
|
+
pipeline_task_role_arn=settings.pipeline_task_role_arn,
|
|
71
|
+
pipeline_log_group=settings.pipeline_log_group,
|
|
72
|
+
pipeline_subnets=settings.pipeline_subnet_list,
|
|
73
|
+
pipeline_security_groups=settings.pipeline_sg_list,
|
|
74
|
+
lambda_execution_role_arn=settings.lambda_execution_role_arn,
|
|
75
|
+
lambda_subnets=settings.lambda_subnet_list,
|
|
76
|
+
lambda_security_groups=settings.lambda_sg_list,
|
|
77
|
+
lambda_log_group=settings.lambda_log_group,
|
|
78
|
+
)
|
|
79
|
+
return _provisioner
|
|
80
|
+
except Exception:
|
|
81
|
+
logger.warning("Failed to initialize InfraProvisioner", exc_info=True)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Container dispatch helpers
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _dispatch_ecs(
|
|
91
|
+
image: str,
|
|
92
|
+
env: dict[str, str],
|
|
93
|
+
timeout: int,
|
|
94
|
+
cluster: str | None = None,
|
|
95
|
+
subnets: list[str] | None = None,
|
|
96
|
+
security_groups: list[str] | None = None,
|
|
97
|
+
cpu_units: int = 256,
|
|
98
|
+
results_backend: dict[str, Any] | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Launch an ECS Fargate task and wait for completion.
|
|
101
|
+
|
|
102
|
+
If InfraProvisioner is configured, auto-provisions the task definition
|
|
103
|
+
and uses cluster/networking from settings. Otherwise falls back to
|
|
104
|
+
hardcoded defaults for backward compatibility.
|
|
105
|
+
"""
|
|
106
|
+
import boto3
|
|
107
|
+
|
|
108
|
+
provisioner = _get_provisioner()
|
|
109
|
+
|
|
110
|
+
# Resolve settings — provisioner path uses Terraform-created infra
|
|
111
|
+
try:
|
|
112
|
+
from pulse_engine.config import get_settings
|
|
113
|
+
|
|
114
|
+
settings = get_settings()
|
|
115
|
+
task_def_family = settings.pipeline_task_definition
|
|
116
|
+
except Exception:
|
|
117
|
+
task_def_family = "pulse-pipeline-step"
|
|
118
|
+
|
|
119
|
+
if provisioner:
|
|
120
|
+
# Auto-provision task definition if it doesn't exist
|
|
121
|
+
provisioner.ensure_ecs_task_definition(family=task_def_family)
|
|
122
|
+
cluster = cluster or provisioner._cluster
|
|
123
|
+
subnets = subnets or provisioner._subnets
|
|
124
|
+
security_groups = security_groups or provisioner._security_groups
|
|
125
|
+
region = provisioner._region
|
|
126
|
+
else:
|
|
127
|
+
cluster = cluster or "pulse-pipeline"
|
|
128
|
+
region = boto3.session.Session().region_name or "us-east-1"
|
|
129
|
+
|
|
130
|
+
ecs = boto3.client("ecs", region_name=region)
|
|
131
|
+
|
|
132
|
+
# containerOverrides only supports: name, command, environment, cpu, memory
|
|
133
|
+
# The container has entrypoint.py which exports run(). We invoke it via
|
|
134
|
+
# a self-contained Python script that reads env vars, calls run(), and
|
|
135
|
+
# writes the output JSON to S3 so the orchestrator can read it back.
|
|
136
|
+
step_name = env.get("STEP_NAME", "unknown")
|
|
137
|
+
pipeline_run_id_val = env.get("PIPELINE_RUN_ID", "unknown")
|
|
138
|
+
|
|
139
|
+
# Resolve S3 bucket/prefix from results_backend override or engine settings
|
|
140
|
+
try:
|
|
141
|
+
from pulse_engine.config import get_settings
|
|
142
|
+
|
|
143
|
+
default_bucket = get_settings().pulse_s3_bucket
|
|
144
|
+
except Exception:
|
|
145
|
+
default_bucket = env.get("PULSE_S3_BUCKET", "pulse-data")
|
|
146
|
+
|
|
147
|
+
rb = results_backend or {}
|
|
148
|
+
s3_bucket = rb.get("bucket") or default_bucket
|
|
149
|
+
s3_prefix = rb.get("prefix", "pipeline-runs/").rstrip("/")
|
|
150
|
+
output_s3_key = f"{s3_prefix}/{pipeline_run_id_val}/output/{step_name}.json"
|
|
151
|
+
|
|
152
|
+
# Inject the output S3 location into the container environment
|
|
153
|
+
env["OUTPUT_S3_BUCKET"] = s3_bucket
|
|
154
|
+
env["OUTPUT_S3_KEY"] = output_s3_key
|
|
155
|
+
|
|
156
|
+
# Always offload INPUTS to S3 — avoids ECS containerOverrides 8192-byte
|
|
157
|
+
# limit regardless of payload size, and gives a consistent data path.
|
|
158
|
+
inputs_raw = env.pop("INPUTS", None)
|
|
159
|
+
if inputs_raw is not None:
|
|
160
|
+
import boto3 as _boto3
|
|
161
|
+
|
|
162
|
+
inputs_s3_key = f"{s3_prefix}/{pipeline_run_id_val}/input/{step_name}.json"
|
|
163
|
+
_boto3.client("s3").put_object(
|
|
164
|
+
Bucket=s3_bucket,
|
|
165
|
+
Key=inputs_s3_key,
|
|
166
|
+
Body=inputs_raw.encode(),
|
|
167
|
+
ContentType="application/json",
|
|
168
|
+
)
|
|
169
|
+
env["INPUTS_S3_KEY"] = inputs_s3_key
|
|
170
|
+
logger.info(
|
|
171
|
+
"Stored INPUTS at s3://%s/%s (%d bytes)",
|
|
172
|
+
s3_bucket,
|
|
173
|
+
inputs_s3_key,
|
|
174
|
+
len(inputs_raw),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
runner_script = (
|
|
178
|
+
"import os, json, sys, boto3; "
|
|
179
|
+
"sys.path.insert(0, '/app'); "
|
|
180
|
+
"sys.path.insert(0, '/app/src'); "
|
|
181
|
+
"from entrypoint import run; "
|
|
182
|
+
"args = json.loads(os.environ.get('ARGS', '{}')); "
|
|
183
|
+
"s3k = os.environ.get('INPUTS_S3_KEY'); "
|
|
184
|
+
"b = os.environ['OUTPUT_S3_BUCKET']; "
|
|
185
|
+
"raw = boto3.client('s3').get_object(Bucket=b,Key=s3k)['Body'].read().decode()"
|
|
186
|
+
" if s3k else os.environ.get('INPUTS', '{}'); "
|
|
187
|
+
"inputs = json.loads(raw); "
|
|
188
|
+
"result = run("
|
|
189
|
+
"job_id=os.environ.get('PIPELINE_RUN_ID',''), "
|
|
190
|
+
"args=args, "
|
|
191
|
+
"inputs=inputs, "
|
|
192
|
+
"pipeline_run_id=os.environ.get('PIPELINE_RUN_ID',''), "
|
|
193
|
+
"pulse_engine_url=os.environ.get('PULSE_ENGINE_URL',''), "
|
|
194
|
+
"pulse_api_token=os.environ.get('PULSE_API_TOKEN','')); "
|
|
195
|
+
"boto3.client('s3').put_object("
|
|
196
|
+
"Bucket=os.environ['OUTPUT_S3_BUCKET'], "
|
|
197
|
+
"Key=os.environ['OUTPUT_S3_KEY'], "
|
|
198
|
+
"Body=json.dumps(result).encode(), "
|
|
199
|
+
"ContentType='application/json'); "
|
|
200
|
+
"print(json.dumps(result))"
|
|
201
|
+
)
|
|
202
|
+
# The task definition sets entryPoint: ["python", "-c"] so that
|
|
203
|
+
# containerOverrides.command is passed as args to python -c.
|
|
204
|
+
container_override = {
|
|
205
|
+
"name": "step",
|
|
206
|
+
"environment": [{"name": k, "value": v} for k, v in env.items()],
|
|
207
|
+
"command": [runner_script],
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
network_config = {}
|
|
211
|
+
if subnets:
|
|
212
|
+
network_config = {
|
|
213
|
+
"awsvpcConfiguration": {
|
|
214
|
+
"subnets": subnets,
|
|
215
|
+
"securityGroups": security_groups or [],
|
|
216
|
+
"assignPublicIp": "DISABLED",
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Register a one-off task definition with the correct image
|
|
221
|
+
# since containerOverrides cannot change the image
|
|
222
|
+
if provisioner:
|
|
223
|
+
task_def_arn = provisioner.ensure_ecs_task_definition(
|
|
224
|
+
family=task_def_family, image=image, cpu=str(cpu_units)
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
task_def_arn = task_def_family
|
|
228
|
+
|
|
229
|
+
response = ecs.run_task(
|
|
230
|
+
cluster=cluster,
|
|
231
|
+
taskDefinition=task_def_arn,
|
|
232
|
+
launchType="FARGATE",
|
|
233
|
+
overrides={
|
|
234
|
+
"containerOverrides": [container_override],
|
|
235
|
+
},
|
|
236
|
+
networkConfiguration=network_config,
|
|
237
|
+
count=1,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
task_arn = response["tasks"][0]["taskArn"]
|
|
241
|
+
logger.info("ECS task started: %s", task_arn)
|
|
242
|
+
|
|
243
|
+
# Poll for completion
|
|
244
|
+
waiter = ecs.get_waiter("tasks_stopped")
|
|
245
|
+
waiter.wait(
|
|
246
|
+
cluster=cluster,
|
|
247
|
+
tasks=[task_arn],
|
|
248
|
+
WaiterConfig={"Delay": 10, "MaxAttempts": max(timeout // 10, 1)},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
desc = ecs.describe_tasks(cluster=cluster, tasks=[task_arn])
|
|
252
|
+
container_info = desc["tasks"][0]["containers"][0]
|
|
253
|
+
exit_code = container_info.get("exitCode", -1)
|
|
254
|
+
|
|
255
|
+
if exit_code != 0:
|
|
256
|
+
reason = container_info.get("reason", "unknown")
|
|
257
|
+
raise RuntimeError(f"ECS task failed with exit code {exit_code}: {reason}")
|
|
258
|
+
|
|
259
|
+
# Read the module's output from S3
|
|
260
|
+
try:
|
|
261
|
+
import boto3 as _boto3
|
|
262
|
+
|
|
263
|
+
s3_client = _boto3.client("s3", region_name=region)
|
|
264
|
+
obj = s3_client.get_object(Bucket=s3_bucket, Key=output_s3_key)
|
|
265
|
+
output_data: dict[str, Any] = json.loads(obj["Body"].read().decode("utf-8"))
|
|
266
|
+
logger.info("Read step output from s3://%s/%s", s3_bucket, output_s3_key)
|
|
267
|
+
return output_data
|
|
268
|
+
except Exception:
|
|
269
|
+
logger.warning(
|
|
270
|
+
"Could not read step output from S3, returning task metadata",
|
|
271
|
+
exc_info=True,
|
|
272
|
+
)
|
|
273
|
+
return {"task_arn": task_arn, "exit_code": exit_code}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_LAMBDA_MAX_TIMEOUT = 900 # AWS Lambda hard limit: 15 minutes
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def _dispatch_lambda(
|
|
280
|
+
image: str,
|
|
281
|
+
env: dict[str, str],
|
|
282
|
+
timeout: int,
|
|
283
|
+
memory_mb: int = 3008,
|
|
284
|
+
results_backend: dict[str, Any] | None = None,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
"""Invoke an AWS Lambda function synchronously.
|
|
287
|
+
|
|
288
|
+
If InfraProvisioner is configured, auto-provisions the Lambda function
|
|
289
|
+
from the container image before invoking. The Lambda handler reads env
|
|
290
|
+
vars from the event payload, calls run(), and returns the result.
|
|
291
|
+
INPUTS are offloaded to S3 to stay within Lambda's 6 MB payload limit.
|
|
292
|
+
"""
|
|
293
|
+
import boto3
|
|
294
|
+
|
|
295
|
+
provisioner = _get_provisioner()
|
|
296
|
+
|
|
297
|
+
# Lambda function name derived from image (convention: last segment before :tag)
|
|
298
|
+
# e.g., 123.ecr/sample-product-extractor:latest -> sample-product-extractor
|
|
299
|
+
func_name = image.split("/")[-1].split(":")[0]
|
|
300
|
+
|
|
301
|
+
# Lambda hard limit — clamp module timeout to avoid AWS rejection
|
|
302
|
+
lambda_timeout = min(timeout, _LAMBDA_MAX_TIMEOUT)
|
|
303
|
+
|
|
304
|
+
if provisioner:
|
|
305
|
+
provisioner.ensure_lambda_function(
|
|
306
|
+
func_name, image, timeout=lambda_timeout, memory_size=memory_mb
|
|
307
|
+
)
|
|
308
|
+
region = provisioner._region
|
|
309
|
+
else:
|
|
310
|
+
region = boto3.session.Session().region_name or "us-east-1"
|
|
311
|
+
|
|
312
|
+
step_name = env.get("STEP_NAME", "unknown")
|
|
313
|
+
pipeline_run_id_val = env.get("PIPELINE_RUN_ID", "unknown")
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
from pulse_engine.config import get_settings
|
|
317
|
+
|
|
318
|
+
default_bucket = get_settings().pulse_s3_bucket
|
|
319
|
+
except Exception:
|
|
320
|
+
default_bucket = env.get("PULSE_S3_BUCKET", "pulse-data")
|
|
321
|
+
|
|
322
|
+
rb = results_backend or {}
|
|
323
|
+
s3_bucket = rb.get("bucket") or default_bucket
|
|
324
|
+
s3_prefix = rb.get("prefix", "pipeline-runs/").rstrip("/")
|
|
325
|
+
output_s3_key = f"{s3_prefix}/{pipeline_run_id_val}/output/{step_name}.json"
|
|
326
|
+
|
|
327
|
+
env["OUTPUT_S3_BUCKET"] = s3_bucket
|
|
328
|
+
env["OUTPUT_S3_KEY"] = output_s3_key
|
|
329
|
+
|
|
330
|
+
# Offload INPUTS to S3 — Lambda 6 MB payload limit mirrors the ECS 8 KB limit
|
|
331
|
+
inputs_raw = env.pop("INPUTS", None)
|
|
332
|
+
if inputs_raw is not None:
|
|
333
|
+
import boto3 as _boto3
|
|
334
|
+
|
|
335
|
+
inputs_s3_key = f"{s3_prefix}/{pipeline_run_id_val}/input/{step_name}.json"
|
|
336
|
+
_boto3.client("s3").put_object(
|
|
337
|
+
Bucket=s3_bucket,
|
|
338
|
+
Key=inputs_s3_key,
|
|
339
|
+
Body=inputs_raw.encode(),
|
|
340
|
+
ContentType="application/json",
|
|
341
|
+
)
|
|
342
|
+
env["INPUTS_S3_KEY"] = inputs_s3_key
|
|
343
|
+
logger.info(
|
|
344
|
+
"Stored INPUTS at s3://%s/%s (%d bytes)",
|
|
345
|
+
s3_bucket,
|
|
346
|
+
inputs_s3_key,
|
|
347
|
+
len(inputs_raw),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
lambda_client = boto3.client("lambda", region_name=region)
|
|
351
|
+
payload = json.dumps(env)
|
|
352
|
+
|
|
353
|
+
response = lambda_client.invoke(
|
|
354
|
+
FunctionName=func_name,
|
|
355
|
+
InvocationType="RequestResponse",
|
|
356
|
+
Payload=payload.encode(),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
status_code = response["StatusCode"]
|
|
360
|
+
response_payload = json.loads(response["Payload"].read().decode())
|
|
361
|
+
|
|
362
|
+
if status_code != 200 or "FunctionError" in response:
|
|
363
|
+
raise RuntimeError(
|
|
364
|
+
f"Lambda invocation failed ({status_code}): {response_payload}"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Prefer the direct response payload (Lambda returns module output)
|
|
368
|
+
if isinstance(response_payload, dict) and response_payload:
|
|
369
|
+
return response_payload
|
|
370
|
+
|
|
371
|
+
# Fallback: read output from S3
|
|
372
|
+
try:
|
|
373
|
+
s3_client = boto3.client("s3", region_name=region)
|
|
374
|
+
obj = s3_client.get_object(Bucket=s3_bucket, Key=output_s3_key)
|
|
375
|
+
output_data: dict[str, Any] = json.loads(obj["Body"].read().decode("utf-8"))
|
|
376
|
+
return output_data
|
|
377
|
+
except Exception:
|
|
378
|
+
logger.warning("Could not read Lambda output from S3", exc_info=True)
|
|
379
|
+
return {}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def _dispatch_container(
|
|
383
|
+
compute: str,
|
|
384
|
+
image: str,
|
|
385
|
+
step_env: dict[str, str],
|
|
386
|
+
timeout: int,
|
|
387
|
+
memory_mb: int = 3008,
|
|
388
|
+
cpu_units: int = 256,
|
|
389
|
+
results_backend: dict[str, Any] | None = None,
|
|
390
|
+
) -> dict[str, Any]:
|
|
391
|
+
"""Dispatch a container to the appropriate compute backend."""
|
|
392
|
+
if compute == "ecs":
|
|
393
|
+
return await _dispatch_ecs(
|
|
394
|
+
image,
|
|
395
|
+
step_env,
|
|
396
|
+
timeout,
|
|
397
|
+
cpu_units=cpu_units,
|
|
398
|
+
results_backend=results_backend,
|
|
399
|
+
)
|
|
400
|
+
elif compute == "lambda":
|
|
401
|
+
return await _dispatch_lambda(
|
|
402
|
+
image,
|
|
403
|
+
step_env,
|
|
404
|
+
timeout,
|
|
405
|
+
memory_mb=memory_mb,
|
|
406
|
+
results_backend=results_backend,
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
raise ValueError(f"Unknown compute backend: {compute}")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Engine callback helpers
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def _report_step_status(
|
|
418
|
+
pulse_engine_url: str,
|
|
419
|
+
pulse_api_token: str,
|
|
420
|
+
pipeline_run_id: str,
|
|
421
|
+
step_name: str,
|
|
422
|
+
status: str,
|
|
423
|
+
output_ref: dict[str, Any] | None = None,
|
|
424
|
+
error_message: str | None = None,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Report step status back to the Pulse Engine."""
|
|
427
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
428
|
+
response = await client.post(
|
|
429
|
+
f"{pulse_engine_url}/api/v1/pipelines/{pipeline_run_id}/steps/{step_name}/status",
|
|
430
|
+
json={
|
|
431
|
+
"status": status,
|
|
432
|
+
"output_ref": output_ref,
|
|
433
|
+
"error_message": error_message,
|
|
434
|
+
},
|
|
435
|
+
headers={"Authorization": f"Bearer {pulse_api_token}"},
|
|
436
|
+
)
|
|
437
|
+
response.raise_for_status()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# Prefect task for executing a single step
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _run_async(coro: Any) -> Any:
|
|
446
|
+
"""Run an async coroutine from a synchronous context.
|
|
447
|
+
|
|
448
|
+
Handles the case where an event loop may or may not already be running.
|
|
449
|
+
"""
|
|
450
|
+
try:
|
|
451
|
+
loop = asyncio.get_running_loop()
|
|
452
|
+
except RuntimeError:
|
|
453
|
+
loop = None
|
|
454
|
+
|
|
455
|
+
if loop and loop.is_running():
|
|
456
|
+
# We are inside an already-running loop (e.g. Prefect's async internals).
|
|
457
|
+
# Create a new loop in a thread to avoid nested run_until_complete.
|
|
458
|
+
import concurrent.futures
|
|
459
|
+
|
|
460
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
461
|
+
return pool.submit(asyncio.run, coro).result()
|
|
462
|
+
else:
|
|
463
|
+
return asyncio.run(coro)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _task_decorator(**kwargs: Any) -> Any:
|
|
467
|
+
"""Apply @task only when Prefect is installed."""
|
|
468
|
+
if _prefect_task is not None:
|
|
469
|
+
return _prefect_task(**kwargs)
|
|
470
|
+
return lambda fn: fn
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def _resolve_module_env(raw_env: dict[str, Any]) -> dict[str, str]:
|
|
474
|
+
"""Resolve module env — expand SecretRef dicts via Secrets Manager."""
|
|
475
|
+
result: dict[str, str] = {}
|
|
476
|
+
for key, val in raw_env.items():
|
|
477
|
+
if isinstance(val, dict) and "from_secret" in val:
|
|
478
|
+
result[key] = await fetch_secret(val["from_secret"])
|
|
479
|
+
else:
|
|
480
|
+
result[key] = str(val)
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@_task_decorator(name="pipeline-step") # type: ignore[misc]
|
|
485
|
+
async def run_step(
|
|
486
|
+
step_config: dict[str, Any],
|
|
487
|
+
module_images: dict[str, str],
|
|
488
|
+
task_args: dict[str, Any],
|
|
489
|
+
inputs: Any,
|
|
490
|
+
global_config: dict[str, Any],
|
|
491
|
+
pipeline_run_id: str,
|
|
492
|
+
tenant_id: str,
|
|
493
|
+
pulse_engine_url: str,
|
|
494
|
+
pulse_api_token: str,
|
|
495
|
+
results_backend: dict[str, Any] | None = None,
|
|
496
|
+
) -> dict[str, Any]:
|
|
497
|
+
"""Execute a single pipeline step by dispatching a container."""
|
|
498
|
+
step_name = step_config["step"]
|
|
499
|
+
module_type = step_config["module_type"] # → MODULE_NAME env var
|
|
500
|
+
timeout = step_config.get("timeout", 3600)
|
|
501
|
+
|
|
502
|
+
image = module_images.get(module_type)
|
|
503
|
+
if not image:
|
|
504
|
+
raise RuntimeError(f"No image registered for module type '{module_type}'")
|
|
505
|
+
|
|
506
|
+
# Merge static args: yaml config + module args (module args take precedence)
|
|
507
|
+
static_args = {**global_config, **task_args}
|
|
508
|
+
|
|
509
|
+
step_env: dict[str, str] = {
|
|
510
|
+
"MODULE_NAME": module_type,
|
|
511
|
+
"ARGS": json.dumps(static_args),
|
|
512
|
+
"PIPELINE_RUN_ID": pipeline_run_id,
|
|
513
|
+
"STEP_NAME": step_name,
|
|
514
|
+
"TENANT_ID": tenant_id,
|
|
515
|
+
"PULSE_ENGINE_URL": pulse_engine_url,
|
|
516
|
+
"PULSE_API_TOKEN": pulse_api_token,
|
|
517
|
+
}
|
|
518
|
+
if inputs is not None:
|
|
519
|
+
step_env["INPUTS"] = json.dumps(inputs)
|
|
520
|
+
|
|
521
|
+
# Resolve module env (expand SecretRef entries)
|
|
522
|
+
raw_env = step_config.get("env", {})
|
|
523
|
+
if raw_env:
|
|
524
|
+
step_env.update(await _resolve_module_env(raw_env))
|
|
525
|
+
|
|
526
|
+
resources = step_config.get("resources", {})
|
|
527
|
+
compute = resources.get("compute", "ecs")
|
|
528
|
+
memory_mb = resources.get("memory", 3008)
|
|
529
|
+
cpu_units = resources.get("cpu_units", 256)
|
|
530
|
+
try:
|
|
531
|
+
from pulse_engine.config import get_settings
|
|
532
|
+
|
|
533
|
+
compute = get_settings().pipeline_default_compute or compute
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
logger.info(
|
|
538
|
+
"Dispatching step '%s' (compute=%s, module_type=%s, image=%s)",
|
|
539
|
+
step_name,
|
|
540
|
+
compute,
|
|
541
|
+
module_type,
|
|
542
|
+
image,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
await _report_step_status(
|
|
546
|
+
pulse_engine_url, pulse_api_token, pipeline_run_id, step_name, "running"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
result = await _dispatch_container(
|
|
551
|
+
compute, image, step_env, timeout, memory_mb, cpu_units, results_backend
|
|
552
|
+
)
|
|
553
|
+
output_ref = (
|
|
554
|
+
result if isinstance(result, dict | list) else {"status": "completed"}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
await _report_step_status(
|
|
558
|
+
pulse_engine_url,
|
|
559
|
+
pulse_api_token,
|
|
560
|
+
pipeline_run_id,
|
|
561
|
+
step_name,
|
|
562
|
+
"completed",
|
|
563
|
+
output_ref,
|
|
564
|
+
)
|
|
565
|
+
return output_ref
|
|
566
|
+
|
|
567
|
+
except Exception as exc:
|
|
568
|
+
logger.exception("Step '%s' failed", step_name)
|
|
569
|
+
await _report_step_status(
|
|
570
|
+
pulse_engine_url,
|
|
571
|
+
pulse_api_token,
|
|
572
|
+
pipeline_run_id,
|
|
573
|
+
step_name,
|
|
574
|
+
"failed",
|
|
575
|
+
error_message=str(exc),
|
|
576
|
+
)
|
|
577
|
+
raise
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _when_step_deps(when_expr: str, step_names: set[str]) -> set[str]:
|
|
581
|
+
"""Extract step names referenced by $steps.X.* in a when expression."""
|
|
582
|
+
import re
|
|
583
|
+
|
|
584
|
+
refs = re.findall(r"\$steps\.([\w-]+)\.", when_expr)
|
|
585
|
+
return {r for r in refs if r in step_names}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _build_deps_map(dag: list[dict[str, Any]]) -> dict[str, set[str]]:
|
|
589
|
+
"""Return step_name → set of upstream dep step names."""
|
|
590
|
+
from pulse_engine.pipeline.expression import parse_expression
|
|
591
|
+
|
|
592
|
+
step_names = {s["step"] for s in dag}
|
|
593
|
+
deps_map: dict[str, set[str]] = {s["step"]: set() for s in dag}
|
|
594
|
+
|
|
595
|
+
for step in dag:
|
|
596
|
+
deps: set[str] = set()
|
|
597
|
+
|
|
598
|
+
collect_from = step.get("collect_from", [])
|
|
599
|
+
if collect_from:
|
|
600
|
+
deps.update(r for r in collect_from if r in step_names)
|
|
601
|
+
|
|
602
|
+
for_each = step.get("for_each")
|
|
603
|
+
if for_each:
|
|
604
|
+
try:
|
|
605
|
+
expr = parse_expression(for_each)
|
|
606
|
+
if expr.source == "steps" and expr.step and expr.step in step_names:
|
|
607
|
+
deps.add(expr.step)
|
|
608
|
+
except ValueError:
|
|
609
|
+
pass
|
|
610
|
+
|
|
611
|
+
when_expr = step.get("when")
|
|
612
|
+
if when_expr and "$item." not in when_expr:
|
|
613
|
+
deps.update(_when_step_deps(when_expr, step_names))
|
|
614
|
+
|
|
615
|
+
for dep_ref in step.get("depends_on") or []:
|
|
616
|
+
dep_step = dep_ref.get("step") if isinstance(dep_ref, dict) else dep_ref
|
|
617
|
+
if dep_step is not None and dep_step in step_names:
|
|
618
|
+
deps.add(dep_step)
|
|
619
|
+
|
|
620
|
+
deps_map[step["step"]] = deps
|
|
621
|
+
|
|
622
|
+
return deps_map
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _should_run(
|
|
626
|
+
deps: set[str],
|
|
627
|
+
step_statuses: dict[str, str],
|
|
628
|
+
trigger_rule: str | None,
|
|
629
|
+
) -> bool:
|
|
630
|
+
"""Evaluate trigger_rule against upstream statuses.
|
|
631
|
+
|
|
632
|
+
Returns True if step should run.
|
|
633
|
+
"""
|
|
634
|
+
if not deps:
|
|
635
|
+
return True
|
|
636
|
+
rule = trigger_rule or "all_success"
|
|
637
|
+
dep_statuses = [step_statuses.get(d, "succeeded") for d in deps]
|
|
638
|
+
|
|
639
|
+
if rule == "all_success":
|
|
640
|
+
return all(s == "succeeded" for s in dep_statuses)
|
|
641
|
+
if rule == "all_done":
|
|
642
|
+
return all(s in {"succeeded", "failed", "skipped"} for s in dep_statuses)
|
|
643
|
+
if rule == "all_skipped":
|
|
644
|
+
return all(s == "skipped" for s in dep_statuses)
|
|
645
|
+
if rule == "one_succeeded":
|
|
646
|
+
return any(s == "succeeded" for s in dep_statuses)
|
|
647
|
+
if rule == "one_failed":
|
|
648
|
+
return any(s == "failed" for s in dep_statuses)
|
|
649
|
+
if rule == "none_failed":
|
|
650
|
+
return "failed" not in dep_statuses
|
|
651
|
+
if rule == "none_skipped":
|
|
652
|
+
return "skipped" not in dep_statuses
|
|
653
|
+
return True
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _build_execution_order(dag: list[dict[str, Any]]) -> list[list[str]]:
|
|
657
|
+
"""Topological sort of v2 DAG into parallel execution layers.
|
|
658
|
+
|
|
659
|
+
Dependency edges come from:
|
|
660
|
+
- collect_from: [s1, s2] → depends on s1, s2
|
|
661
|
+
- for_each: $steps.S.* → depends on S
|
|
662
|
+
"""
|
|
663
|
+
from pulse_engine.pipeline.expression import parse_expression
|
|
664
|
+
|
|
665
|
+
in_degree: dict[str, int] = {s["step"]: 0 for s in dag}
|
|
666
|
+
dependents: dict[str, list[str]] = {s["step"]: [] for s in dag}
|
|
667
|
+
step_names = set(in_degree)
|
|
668
|
+
|
|
669
|
+
for step in dag:
|
|
670
|
+
deps: set[str] = set()
|
|
671
|
+
|
|
672
|
+
collect_from = step.get("collect_from", [])
|
|
673
|
+
if collect_from:
|
|
674
|
+
deps.update(r for r in collect_from if r in step_names)
|
|
675
|
+
|
|
676
|
+
for_each = step.get("for_each")
|
|
677
|
+
if for_each:
|
|
678
|
+
try:
|
|
679
|
+
expr = parse_expression(for_each)
|
|
680
|
+
if expr.source == "steps" and expr.step and expr.step in step_names:
|
|
681
|
+
deps.add(expr.step)
|
|
682
|
+
except ValueError as e:
|
|
683
|
+
logger.warning(
|
|
684
|
+
"Could not parse for_each expression '%s': %s", for_each, e
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
for dep_ref in step.get("depends_on") or []:
|
|
688
|
+
dep_step = dep_ref.get("step") if isinstance(dep_ref, dict) else dep_ref
|
|
689
|
+
if dep_step is not None and dep_step in step_names:
|
|
690
|
+
deps.add(dep_step)
|
|
691
|
+
|
|
692
|
+
when_expr = step.get("when")
|
|
693
|
+
if when_expr and "$item." not in when_expr:
|
|
694
|
+
deps.update(_when_step_deps(when_expr, step_names))
|
|
695
|
+
|
|
696
|
+
for dep in deps:
|
|
697
|
+
in_degree[step["step"]] += 1
|
|
698
|
+
dependents[dep].append(step["step"])
|
|
699
|
+
|
|
700
|
+
layers: list[list[str]] = []
|
|
701
|
+
queue = [name for name, deg in in_degree.items() if deg == 0]
|
|
702
|
+
|
|
703
|
+
while queue:
|
|
704
|
+
layers.append(sorted(queue))
|
|
705
|
+
next_queue: list[str] = []
|
|
706
|
+
for name in queue:
|
|
707
|
+
for dep_name in dependents[name]:
|
|
708
|
+
in_degree[dep_name] -= 1
|
|
709
|
+
if in_degree[dep_name] == 0:
|
|
710
|
+
next_queue.append(dep_name)
|
|
711
|
+
queue = next_queue
|
|
712
|
+
|
|
713
|
+
return layers
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _flow_decorator(**kwargs: Any) -> Any:
|
|
717
|
+
"""Apply @flow only when Prefect is installed."""
|
|
718
|
+
if _prefect_flow is not None:
|
|
719
|
+
return _prefect_flow(**kwargs)
|
|
720
|
+
return lambda fn: fn
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@_flow_decorator(name="pulse-pipeline-runner", retries=0) # type: ignore[misc]
|
|
724
|
+
def _make_submitter(
|
|
725
|
+
module_images: dict[str, str],
|
|
726
|
+
global_config: dict[str, Any],
|
|
727
|
+
pipeline_run_id: str,
|
|
728
|
+
tenant_id: str,
|
|
729
|
+
pulse_engine_url: str,
|
|
730
|
+
pulse_api_token: str,
|
|
731
|
+
results_backend: dict[str, Any] | None = None,
|
|
732
|
+
) -> Any:
|
|
733
|
+
"""Return a _submit callable bound to the current pipeline run context."""
|
|
734
|
+
|
|
735
|
+
def _submit(
|
|
736
|
+
task_name: str, step_cfg: dict[str, Any], t_args: dict[str, Any], inp: Any
|
|
737
|
+
) -> Any:
|
|
738
|
+
kwargs = {
|
|
739
|
+
"step_config": step_cfg,
|
|
740
|
+
"module_images": module_images,
|
|
741
|
+
"task_args": t_args,
|
|
742
|
+
"inputs": inp,
|
|
743
|
+
"global_config": global_config,
|
|
744
|
+
"pipeline_run_id": pipeline_run_id,
|
|
745
|
+
"tenant_id": tenant_id,
|
|
746
|
+
"pulse_engine_url": pulse_engine_url,
|
|
747
|
+
"pulse_api_token": pulse_api_token,
|
|
748
|
+
"results_backend": results_backend,
|
|
749
|
+
}
|
|
750
|
+
if _prefect_task is not None and hasattr(run_step, "with_options"):
|
|
751
|
+
return run_step.with_options(
|
|
752
|
+
name=task_name,
|
|
753
|
+
retries=step_cfg.get("retries", 0),
|
|
754
|
+
retry_delay_seconds=step_cfg.get("retry_delay", 0),
|
|
755
|
+
timeout_seconds=step_cfg.get("timeout", 3600),
|
|
756
|
+
).submit(**kwargs)
|
|
757
|
+
return run_step(**kwargs)
|
|
758
|
+
|
|
759
|
+
return _submit
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _execute_layer(
|
|
763
|
+
layer: list[str],
|
|
764
|
+
step_map: dict[str, Any],
|
|
765
|
+
step_results: dict[str, Any],
|
|
766
|
+
step_statuses: dict[str, str],
|
|
767
|
+
deps_map: dict[str, set[str]],
|
|
768
|
+
submit: Any,
|
|
769
|
+
) -> None:
|
|
770
|
+
"""Submit all steps in a layer, evaluating trigger_rule and when conditions."""
|
|
771
|
+
from pulse_engine.pipeline.expression import (
|
|
772
|
+
build_task_args,
|
|
773
|
+
collect_inputs,
|
|
774
|
+
evaluate_when,
|
|
775
|
+
parse_expression,
|
|
776
|
+
resolve_for_each_items,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
layer_futures: list[tuple[str, Any]] = []
|
|
780
|
+
layer_fanout: list[tuple[str, list[Any]]] = []
|
|
781
|
+
|
|
782
|
+
for step_name in layer:
|
|
783
|
+
step_config = step_map[step_name]
|
|
784
|
+
module_args: dict[str, Any] = step_config.get("args", {})
|
|
785
|
+
collect_from: list[str] = step_config.get("collect_from", [])
|
|
786
|
+
trigger_rule: str | None = step_config.get("trigger_rule")
|
|
787
|
+
when_expr: str | None = step_config.get("when")
|
|
788
|
+
deps = deps_map.get(step_name, set())
|
|
789
|
+
|
|
790
|
+
# Gate 1: trigger_rule
|
|
791
|
+
if not _should_run(deps, step_statuses, trigger_rule):
|
|
792
|
+
logger.info(
|
|
793
|
+
"Step '%s' skipped — trigger_rule='%s' not satisfied",
|
|
794
|
+
step_name,
|
|
795
|
+
trigger_rule or "all_success",
|
|
796
|
+
)
|
|
797
|
+
step_statuses[step_name] = "skipped"
|
|
798
|
+
step_results[step_name] = {}
|
|
799
|
+
continue
|
|
800
|
+
|
|
801
|
+
# Gate 2: step-level when (only when expression doesn't reference $item.*)
|
|
802
|
+
if when_expr and "$item." not in when_expr:
|
|
803
|
+
if not evaluate_when(when_expr, step_results, step_statuses):
|
|
804
|
+
logger.info("Step '%s' skipped — when condition false", step_name)
|
|
805
|
+
step_statuses[step_name] = "skipped"
|
|
806
|
+
step_results[step_name] = {}
|
|
807
|
+
continue
|
|
808
|
+
|
|
809
|
+
if step_config.get("for_each"):
|
|
810
|
+
expr = parse_expression(step_config["for_each"])
|
|
811
|
+
items = resolve_for_each_items(
|
|
812
|
+
expr, module_args, step_results, collect_from or None
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# Item-level when filter (fan-out only)
|
|
816
|
+
if when_expr and "$item." in when_expr:
|
|
817
|
+
items = [
|
|
818
|
+
it
|
|
819
|
+
for it in items
|
|
820
|
+
if evaluate_when(when_expr, step_results, step_statuses, it)
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
futures = [
|
|
824
|
+
# Give each fan-out task a unique step name so S3 input/output
|
|
825
|
+
# keys don't collide (all tasks share the same base step_config).
|
|
826
|
+
submit(
|
|
827
|
+
f"{step_name}[{i}]",
|
|
828
|
+
{**step_config, "step": f"{step_name}[{i}]"},
|
|
829
|
+
*build_task_args(expr, module_args, item),
|
|
830
|
+
)
|
|
831
|
+
for i, item in enumerate(items)
|
|
832
|
+
]
|
|
833
|
+
logger.info("Fan-out step '%s': %d tasks submitted", step_name, len(items))
|
|
834
|
+
layer_fanout.append((step_name, futures))
|
|
835
|
+
elif collect_from:
|
|
836
|
+
inputs_val = collect_inputs(collect_from, step_results)
|
|
837
|
+
layer_futures.append(
|
|
838
|
+
(step_name, submit(step_name, step_config, module_args, inputs_val))
|
|
839
|
+
)
|
|
840
|
+
else:
|
|
841
|
+
layer_futures.append(
|
|
842
|
+
(step_name, submit(step_name, step_config, module_args, None))
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
for step_name, fut in layer_futures:
|
|
846
|
+
try:
|
|
847
|
+
step_results[step_name] = fut.result() if hasattr(fut, "result") else fut
|
|
848
|
+
step_statuses[step_name] = "succeeded"
|
|
849
|
+
except Exception:
|
|
850
|
+
step_results[step_name] = {}
|
|
851
|
+
step_statuses[step_name] = "failed"
|
|
852
|
+
logger.exception("Step '%s' failed", step_name)
|
|
853
|
+
|
|
854
|
+
for step_name, fan_futures in layer_fanout:
|
|
855
|
+
outputs = []
|
|
856
|
+
fan_failed = False
|
|
857
|
+
for f in fan_futures:
|
|
858
|
+
try:
|
|
859
|
+
outputs.append(f.result() if hasattr(f, "result") else f)
|
|
860
|
+
except Exception:
|
|
861
|
+
fan_failed = True
|
|
862
|
+
logger.exception("Fan-out task in step '%s' failed", step_name)
|
|
863
|
+
step_results[step_name] = outputs
|
|
864
|
+
step_statuses[step_name] = "failed" if fan_failed else "succeeded"
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def pipeline_flow(
|
|
868
|
+
pipeline_run_id: str,
|
|
869
|
+
tenant_id: str,
|
|
870
|
+
dag: list[dict[str, Any]],
|
|
871
|
+
module_images: dict[str, str],
|
|
872
|
+
global_config: dict[str, Any],
|
|
873
|
+
pulse_engine_url: str,
|
|
874
|
+
pulse_api_token: str,
|
|
875
|
+
results_backend: dict[str, Any] | None = None,
|
|
876
|
+
) -> None:
|
|
877
|
+
"""Generic v2 pipeline flow — interprets DAG, dispatches containers per step."""
|
|
878
|
+
logger.info(
|
|
879
|
+
"Starting v2 pipeline flow: run_id=%s, steps=%d", pipeline_run_id, len(dag)
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
step_map = {s["step"]: s for s in dag}
|
|
883
|
+
step_results: dict[str, Any] = {}
|
|
884
|
+
step_statuses: dict[str, str] = {}
|
|
885
|
+
deps_map = _build_deps_map(dag)
|
|
886
|
+
|
|
887
|
+
submit = _make_submitter(
|
|
888
|
+
module_images,
|
|
889
|
+
global_config,
|
|
890
|
+
pipeline_run_id,
|
|
891
|
+
tenant_id,
|
|
892
|
+
pulse_engine_url,
|
|
893
|
+
pulse_api_token,
|
|
894
|
+
results_backend,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
for layer in _build_execution_order(dag):
|
|
898
|
+
_execute_layer(layer, step_map, step_results, step_statuses, deps_map, submit)
|
|
899
|
+
|
|
900
|
+
failed = [s for s, st in step_statuses.items() if st == "failed"]
|
|
901
|
+
if failed:
|
|
902
|
+
raise RuntimeError(f"Pipeline {pipeline_run_id} failed — steps: {failed}")
|
|
903
|
+
|
|
904
|
+
logger.info("Pipeline flow completed: run_id=%s", pipeline_run_id)
|