Files
distributed-execution/src/distributed_execution/loosely_coupled/jobs.py
ILay 6532d640f9 [SIMPL-30451] Point image references at the project registry path
The GitLab project lives at simpl-open/data/supporting-data-services/distributed-execution/distributed-execution, so CI_REGISTRY_IMAGE ends in the project name twice. The pipeline pushes to CI_REGISTRY_IMAGE and nowhere else, so the earlier one-segment path named an image nobody builds - the same mismatch that field-level-pseudo-anonymisation and dataframe-level-anonymisation carry today.

Changelog: fixed
2026-08-31 16:13:05 +02:00

170 lines
5.7 KiB
Python

"""Loosely coupled reference implementations.
Dagster does not run the work here. It dispatches an external payload and then
acts as a listener: the payload writes structured messages over the
dagster-pipes protocol, which Dagster materialises as logs and metadata. The
payload never connects to the metadata database.
Two transports are provided. Both dispatch the *same* payload script and produce
the same output shape as the tightly coupled ``process_work_unit`` op, so the
graphs differ by exactly one node - see the guide's section 5.3.
"""
import os
import sys
from pathlib import Path
from dagster import (
Failure,
OpExecutionContext,
Out,
PipesSubprocessClient,
graph,
in_process_executor,
multiprocess_executor,
op,
)
from dagster_k8s import PipesK8sClient
from distributed_execution.ops import (
generate_work_units,
report_execution_target,
summarise_results,
)
# Payload lives outside src/ so it can be built into its own image.
_REPO_ROOT = Path(__file__).resolve().parents[3]
PAYLOAD_SCRIPT = os.environ.get("PIPES_PAYLOAD_SCRIPT", str(_REPO_ROOT / "payload" / "work.py"))
PAYLOAD_IMAGE = os.environ.get(
"PIPES_PAYLOAD_IMAGE",
"code.europa.eu:4567/simpl/simpl-open/data/supporting-data-services"
"/distributed-execution/distributed-execution/payload:0.0.1",
)
PAYLOAD_NAMESPACE = os.environ.get("PIPES_PAYLOAD_NAMESPACE", "dagster")
COMMON_TAGS = {
"execution_target": "loosely_coupled",
"business_operation": "DISTRIBUTED_EXECUTION_REFERENCE",
}
def _result_from_pipes(context: OpExecutionContext, completed) -> dict:
"""Read one unit's result off the message channel.
A broken message path is the defining failure mode of this pattern: the
external workload can exit 0 while reporting nothing, so silence is treated
as a failure rather than an empty result.
"""
messages = completed.get_custom_messages()
if not messages:
raise Failure(
description=(
"No pipes messages received from the external payload. The workload may have "
"run successfully while its messages were lost. Verify the message path: for "
"the Kubernetes transport, confirm the pod log stream is readable and not "
"being truncated or intercepted by a log shipper."
)
)
payload = messages[-1]
leaked = payload.get("orchestration_env_visible") or []
if leaked:
context.log.warning(
"Payload could see orchestration runtime credentials: %s. "
"This defeats the isolation the loosely coupled target is chosen for.",
", ".join(leaked),
)
row = payload["results"][0]
context.log.info("Unit %s computed by external worker %s", row["unit"], row["worker"])
return row
@op(
description="Dispatches one external payload as a local subprocess and listens on the pipes channel.",
out=Out(dict),
)
def dispatch_external_work_subprocess(
context: OpExecutionContext,
unit: int,
pipes_subprocess_client: PipesSubprocessClient,
) -> dict:
completed = pipes_subprocess_client.run(
context=context,
command=[sys.executable, PAYLOAD_SCRIPT],
extras={"units": [unit]},
)
return _result_from_pipes(context, completed)
@op(
description="Dispatches one external payload as a Kubernetes Job and listens on the pod log stream.",
out=Out(dict),
)
def dispatch_external_work_k8s(
context: OpExecutionContext,
unit: int,
pipes_k8s_client: PipesK8sClient,
) -> dict:
completed = pipes_k8s_client.run(
context=context,
image=PAYLOAD_IMAGE,
command=["python", "/app/work.py"],
namespace=PAYLOAD_NAMESPACE,
extras={"units": [unit]},
base_pod_meta={
"labels": {
"app.kubernetes.io/name": "distributed-execution-payload",
"dagster/execution-target": "loosely-coupled",
}
},
)
return _result_from_pipes(context, completed)
@graph
def loosely_coupled_subprocess_reference():
target_report = report_execution_target()
units = generate_work_units()
results = units.map(dispatch_external_work_subprocess).collect()
return summarise_results(results, target_report)
@graph
def loosely_coupled_k8s_reference():
target_report = report_execution_target()
units = generate_work_units()
results = units.map(dispatch_external_work_k8s).collect()
return summarise_results(results, target_report)
loosely_coupled_subprocess_job = loosely_coupled_subprocess_reference.to_job(
name="loosely_coupled_subprocess_job",
description=(
"Loosely coupled via subprocess transport. Demonstrates the pipes contract end to end "
"on a laptop, with no cluster and no metadata database access from the payload."
),
# Dispatch is I/O-bound and the real work happens in the payload, so orchestrating
# in one process keeps the laptop demo free of platform-specific spawn behaviour.
executor_def=in_process_executor,
resource_defs={"pipes_subprocess_client": PipesSubprocessClient()},
tags={**COMMON_TAGS, "transport": "subprocess"},
)
loosely_coupled_k8s_job = loosely_coupled_k8s_reference.to_job(
name="loosely_coupled_k8s_job",
description=(
"Loosely coupled via Kubernetes Job dispatch. Messages return over the pod log stream. "
"Requires a cluster and RBAC to create Jobs in the target namespace."
),
executor_def=multiprocess_executor.configured({"max_concurrent": 2}),
resource_defs={"pipes_k8s_client": PipesK8sClient()},
tags={**COMMON_TAGS, "transport": "k8s_pod_logs"},
)
LOOSELY_COUPLED_JOBS = [
loosely_coupled_subprocess_job,
loosely_coupled_k8s_job,
]