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,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)