Dagster integration¶
Install the optional integration with:
OCLP and Dagster declare different things. OCLP decorators declare durable application semantics: Artifact representations, Computation contracts, and ArtifactSets. Native Dagster assets declare orchestration: asset keys, dependencies, partitions, retries, I/O, and schedules.
The adapter connects those two layers at runtime. It does not infer a
Computation from an arbitrary @dagster.asset, create synthetic OCLP records,
or hide data dependencies behind a monolithic workflow asset.
The mixed-decorator contract¶
A native Dagster asset and a canonical OCLP declaration can describe one application operation, but they do not describe the same facts. Keep both declarations explicit and keep their shared boundary in lockstep.
Decorator order¶
The native Dagster decorator is always outermost. @computation is closest to
the function; optional OCLP extensions such as @mlflow and @artifact_set
sit outside it:
@dg.multi_asset(...) # native Dagster orchestration: outermost
@artifact_set(...) # optional OCLP output-role declaration
@mlflow(...) # optional OCLP projection declaration
@computation(
...,
adapters=(dagster_adapter(),),
)
def train_orders(...) -> dict[str, object]:
...
Python applies decorators from the bottom up. Dagster therefore invokes the OCLP-decorated callable, and the inner adapter can open an OCLP observation using the real Dagster execution context. Reversing the order gives an OCLP declaration a Dagster asset definition rather than an ordinary function.
For an acquisition boundary, use the same outer/inner order with an Artifact
decorator instead of @computation:
@dg.asset(...)
@csv_artifact(..., adapters=(dagster_adapter(),))
def load_orders(...) -> pd.DataFrame:
...
That creates an OCLP Artifact but no OCLP Computation or Execution, so it has no multi-output contract.
Which layer owns which facts¶
| Concern | Owner |
|---|---|
| Asset keys, dependencies, partitions, groups, retry policy, scheduler configuration, and I/O-manager selection | Native @dg.asset or @dg.multi_asset |
| Computation identity, OCLP Artifact representation and metadata, portable input/output ports, Evidence, ArtifactSet roles, and MLflow projections | Canonical OCLP decorators |
| Opening the observation, publishing OCLP records, passing scheduler facts, and returning OCLP handles to Dagster | dagster_adapter() |
The adapter must appear in the canonical declaration's adapters= tuple, and
the outer asset must declare required_resource_keys={"oclp"}. The resource
provides the publisher, implementation source, and any project-level OCLP
adapters or lifecycle configuration.
Input contract¶
For a Computation asset, these three names identify the same local input port:
@dg.asset(ins={"orders": dg.AssetIn(key=dg.AssetKey("raw_orders"))})
@computation(inputs={"orders": JsonArtifact}, adapters=(dagster_adapter(),))
def prepare_orders(orders: dict[str, object]) -> dict[str, object]:
...
"orders"ininsis the Dagster input name."orders"ininputsis the OCLP port name.ordersis the Python function parameter.
The upstream Dagster AssetKey may use a different name, as raw_orders does
above. When Artifacts cross Dagster workers or runs, use the OCLP Artifact I/O
manager: Dagster passes the exact ArtifactHandle, and OCLP verifies and loads
the normal domain value for the function body. The function does not receive a
shadow copy invented for OCLP.
context: dg.AssetExecutionContext is an optional implementation parameter.
Use it only when the body needs scheduler facts such as a partition key. The
adapter injects it; it is neither an OCLP input Artifact nor an OCLP Execution
parameter.
Multi-output contract¶
Dagster outputs and OCLP output ports are independent sets with an optional named overlap. A shared name is bridged automatically; each unshared output remains owned by its declaring layer:
@dg.multi_asset(
outs={
"validated": dg.AssetOut(key="validated_orders", io_manager_key="oclp_io"),
"preview": dg.AssetOut(key="validation_preview"),
},
ins={"orders": dg.AssetIn(key=dg.AssetKey("raw_orders"))},
required_resource_keys={"oclp"},
)
@computation(
name="Validate orders",
inputs={"orders": JsonArtifact},
outputs={
"validated": JsonArtifact(name="Validated orders"),
"metrics": JsonArtifact(name="Order metrics"),
},
adapters=(dagster_adapter(),),
)
def validate_orders(orders: dict[str, object]) -> dict[str, object]:
# One named result covers the union of native and OCLP outputs.
return {
"validated": orders,
"preview": {"kind": "validation-preview"},
"metrics": {"rows": len(orders)},
}
validated is shared: OCLP materializes its Artifact, and the adapter returns
its exact ArtifactHandle to Dagster. preview is Dagster-only: the adapter
passes its raw value to Dagster, which uses the output's selected native I/O
manager. metrics is OCLP-only: OCLP materializes it and records it on the
Execution, but it is not a Dagster asset.
For several outputs, the application function must return a named mapping or
an object with same-named fields, such as a dataclass or NamedTuple. Do not
return a positional tuple or list. A single-output function may return its raw
value directly. This preserves each Artifact's identity by name even when
Dagster and OCLP expose different subsets:
from typing import NamedTuple
class ValidationOutputs(NamedTuple):
validated: dict[str, object] # shared
preview: dict[str, object] # Dagster-only
metrics: dict[str, int] # OCLP-only
Only after OCLP has materialized its declared outputs does the adapter build
the native return value. It returns an ArtifactHandle for each shared Dagster
output and the raw value for each Dagster-only output, in the native Dagster
outs order. OCLP-only Artifacts are deliberately omitted. The OCLP Artifact
I/O manager persists UUID pointers, not duplicate payload data, for shared
outputs that cross worker or Dagster-run boundaries.
When the local names differ, add an explicit adapter binding from native output name to OCLP port; same-named outputs need no configuration:
The adapter validates explicit bindings against the native Dagster outputs and declared OCLP ports when the asset executes.
Independent use and combined use¶
@dg.multi_asset does not import-time depend on @computation: without the
OCLP declaration it behaves as a normal Dagster multi-asset returning ordinary
Python values. Likewise, @computation can run outside Dagster inside a normal
OCLP @run / observe_run(...) observation. Adding dagster_adapter() makes
the combined invocation intentionally depend on the contract above: one body
produces real OCLP Artifacts and the exact handles flow through Dagster.
Why the adapter is required¶
@computation and Artifact decorators are reusable declarations. Outside an
active observe_run(...), they call their ordinary Python body; stacking only
@dg.asset and @computation therefore does not publish OCLP records.
Pass dagster_adapter() through the canonical decorator's adapters= tuple.
When Dagster invokes the asset, it opens the OCLP observation, publishes the
real records, returns exact output handles to Dagster, and writes navigation
metadata to the materialization. The adapter is runtime activation, not an
Artifact, Computation, or Dagster asset decorator itself.
Before declaring assets: configure the integration once¶
Register project-level OCLP facts once in Dagster Definitions:
from oclp import MlflowAdapter
from oclp.dagster import oclp_dagster_resource
resources = {
"oclp": oclp_dagster_resource(
publisher=publisher_for_context,
source=source_for_context,
adapters=(
MlflowAdapter(
experiment_name="daily-training",
parent_profile="my_app.mlflow-parent", # optional
),
),
lifecycle=lifecycle_for_context, # optional
profiles=profiles_for_context, # optional
)
}
publisher and source are required because they select where records are
published and how their implementation is identified. adapters holds normal
OCLP runtime integrations, such as an optional MlflowAdapter; lifecycle
and profiles are optional application facts. Configure defaults on the
resource when they apply to the project, or override either application fact on
a specific declaration with dagster_adapter(lifecycle=..., profiles=...).
Optional runtime mirrors: MLflow¶
@mlflow on a Computation only declares which metrics or payloads to project.
It is inert until the Dagster resource receives an MlflowAdapter through its
adapters= tuple. The resource creates a fresh adapter session for every OCLP
observation opened by a native asset, so the adapter remains safe across
separate Dagster workers and retries.
For a release cycle that spans jobs, create the persistent MLflow parent before
the cycle begins, place its ID in an application profile, and set
parent_profile on the resource-level MlflowAdapter. Every native asset
observation then becomes an MLflow child run with the standard
mlflow.parentRunId tag. The bike-demand example uses this arrangement: its
release-cycle Artifact persists the idempotently created parent ID, every later
asset reads that Artifact into the profile, and the terminal release sensor
marks the parent finished.
The adapter derives an OCLP run label from the canonical Artifact, Computation,
or ArtifactSet declaration. It derives a stable OCLP run UUID from the Dagster
run plus the specific asset attempt. The raw scheduler run ID, asset key, step key,
partition key, and retry number remain in the generic dagster profile.
For a partition whose display label needs application-specific detail, pass a
context-aware run_name to the inner adapter. For example, the bike-demand
fold declaration names its Runs Bike demand temporal validation fold 1,
2, and so on, while each child Execution remains Train bike demand fold:
@computation(
name="Train bike demand fold",
adapters=(
dagster_adapter(
run_name=lambda context: (
f"Bike demand temporal validation fold {fold_number(context)}"
)
),
),
)
def train_fold(...):
...
The bike-demand Dagster implementation¶
The bike-demand tutorial deliberately describes the ordinary OCLP runner. Its Dagster implementation is separate:
data.py / modeling.py / runner.py ordinary OCLP runner; no Dagster import
dagster/ native assets, partitions, sensors, jobs, and resources
dagster_defs.py thin Dagster code-location entry point
The dagster package does not call an OCLP-decorated function from the ordinary
runner. It declares its own OCLP contracts with the SDK's canonical decorators,
then applies dagster_adapter() at that native asset boundary. This keeps
Dagster out of the ordinary runner and avoids hidden nested OCLP observations.
The implementations may share ordinary domain helpers, but never application-
defined OCLP decorator factories.
For example, the Dagster source asset uses the same CSV Artifact contract as the ordinary acquisition while directly calling the undecorated fetch operation:
from oclp import (
CsvArtifact,
JsonArtifact,
MlflowMetrics,
artifact_set,
computation,
csv_artifact,
mlflow,
)
from bike_demand_service.data import UCI_BIKE_SHARING_DATASET_ID, _fetch_source_frame
@dg.asset(
key="bike_demand_raw_source",
io_manager_key="oclp_artifact_io_manager",
required_resource_keys={"oclp"},
)
@csv_artifact(
name="UCI Bike Sharing source (CSV)",
description_from_docstring=True,
index=False,
lineterminator="\n",
adapters=(dagster_adapter(),),
)
def bike_demand_raw_source() -> pd.DataFrame:
"""Acquire the source snapshot for one training cycle."""
return _fetch_source_frame(UCI_BIKE_SHARING_DATASET_ID)
The multi-output feature asset likewise declares its Computation, MLflow, and
ArtifactSet contracts directly. Native Dagster fields stay on
@dg.multi_asset; OCLP facts stay on SDK decorators:
@dg.multi_asset(
outs={
"features": dg.AssetOut(key="bike_demand_features", io_manager_key="oclp_io"),
"fold_definition": dg.AssetOut(
key="bike_demand_fold_definition", io_manager_key="oclp_io"
),
"feature_contract": dg.AssetOut(
key="bike_demand_feature_contract", io_manager_key="oclp_io"
),
"data_metrics": dg.AssetOut(
key="bike_demand_data_metrics", io_manager_key="oclp_io"
),
},
ins={
"source_snapshot": dg.AssetIn(key=dg.AssetKey("bike_demand_raw_source")),
"training_plan": dg.AssetIn(key=dg.AssetKey("bike_demand_training_plan")),
},
required_resource_keys={"oclp"},
)
@artifact_set(
name="Bike demand CatBoost release",
members={
"features": ("features", "training-data"),
"feature-contract": ("feature_contract", "serving-contract"),
},
)
@mlflow(metrics=(MlflowMetrics(output_port="data_metrics", prefix="data"),))
@computation(
name="Prepare bike demand features",
inputs={"source_snapshot": CsvArtifact, "training_plan": JsonArtifact},
outputs={
"features": CsvArtifact(name="Bike demand features"),
"fold_definition": JsonArtifact(name="Temporal fold definition"),
"feature_contract": JsonArtifact(name="Feature contract"),
"data_metrics": JsonArtifact(name="Training data metrics"),
},
adapters=(dagster_adapter(),),
)
def bike_demand_prepare_features(
source_snapshot: pd.DataFrame,
training_plan: dict[str, object],
) -> dict[str, object]:
return _prepare_features_value(source_snapshot, training_plan)
The first example’s @dg.asset, and the second example’s @dg.multi_asset,
are intentionally outermost. The OCLP declaration closest to the function
defines the real Artifact or Computation; the adapter makes that declaration
active only when Dagster calls this separate implementation. The full working
graph, including the exact concrete Artifact declarations, dynamic partitions,
sensors, cross-run ArtifactSet assembly, and release lifecycle, is in
examples/bike-demand-service/src/bike_demand_service/dagster/definitions.py.
How to configure a Computation asset¶
The canonical OCLP decorator is the inner layer; the native Dagster decorator is outermost. Python applies decorators from the bottom up, so this order is required:
import dagster as dg
from oclp import JsonArtifact, computation
from oclp.dagster import dagster_adapter
@dg.asset(
key="prepared_orders",
ins={"orders": dg.AssetIn(key=dg.AssetKey("raw_orders"))},
required_resource_keys={"oclp"},
)
@computation(
name="Prepare orders",
inputs={"orders": JsonArtifact},
outputs={"prepared": JsonArtifact(name="Prepared orders")},
adapters=(dagster_adapter(),),
)
def prepare_orders(orders: dict[str, object]) -> dict[str, object]:
return {"prepared_rows": len(orders)}
The native asset owns key, ins, dependencies, partitions, retry policy,
and Dagster configuration. The canonical Computation owns OCLP input/output
ports, Artifact formats, Evidence, MLflow declarations, and other semantic
OCLP metadata. Do not repeat workflow, run_name, publisher, source, or
an OCLP context parameter on every asset.
When an OCLP decorator such as @mlflow or @artifact_set is also present,
keep @computation closest to the function and retain @dg.asset outermost.
Reversing the stack gives an OCLP declaration a Dagster asset definition rather
than an ordinary function.
How to configure an Artifact asset¶
Use an OCLP Artifact decorator when the asset produces a real OCLP Artifact but not an OCLP Computation or Execution:
import dagster as dg
from oclp import json_artifact
from oclp.dagster import dagster_adapter
@dg.asset(
key="raw_orders",
io_manager_key="oclp_io",
required_resource_keys={"oclp"},
)
@json_artifact(name="Raw orders", adapters=(dagster_adapter(),))
def load_orders() -> dict[str, object]:
return {"rows": 42}
Choose the concrete representation that matches the value, such as
@csv_artifact, @parquet_artifact, or @json_artifact. The outer asset
materializes the resulting ArtifactHandle; it does not change the OCLP
semantic record type.
How to configure Computation parameters from Dagster¶
Native asset config remains an outer Dagster concern. If a configured field has the same name as an optional declared Computation parameter, the adapter passes that value through and the resulting Execution records it as that parameter:
@dg.asset(
key="evaluate_orders",
config_schema={"quality_threshold": dg.Field(float, default_value=0.9)},
required_resource_keys={"oclp"},
)
@computation(
name="Evaluate orders",
outputs={"report": JsonArtifact(name="Order quality report")},
adapters=(dagster_adapter(),),
)
def evaluate_orders(*, quality_threshold: float = 0.9) -> dict[str, float]:
return {"quality_threshold": quality_threshold}
This preserves the selected value as an OCLP Execution parameter without making the Dagster context part of the application function's contract.
How to configure a multi-output Computation asset¶
This example makes every output shared. Native Dagster owns the AssetOut
mapping and OCLP owns the Artifact declarations; see the
multi-output contract when either layer needs an
additional private output:
@dg.multi_asset(
outs={
"validated": dg.AssetOut(key="validated_orders", io_manager_key="oclp_io"),
"metrics": dg.AssetOut(key="order_metrics", io_manager_key="oclp_io"),
},
ins={"orders": dg.AssetIn(key=dg.AssetKey("raw_orders"))},
required_resource_keys={"oclp"},
)
@computation(
name="Validate orders",
inputs={"orders": JsonArtifact},
outputs={
"validated": JsonArtifact(name="Validated orders"),
"metrics": JsonArtifact(name="Order metrics"),
},
adapters=(dagster_adapter(),),
)
def validate_orders(orders: dict[str, object]) -> dict[str, object]:
return {"validated": orders, "metrics": {"rows": len(orders)}}
The adapter returns the exact handles in native outs order. A native
multi_asset remains atomic and non-subsettable, matching the one OCLP
Execution that produces all shared output Artifacts.
How to pass Artifacts between Dagster assets¶
Use the catalog-backed I/O manager whenever an Artifact can cross a worker or Dagster-run boundary:
from pathlib import Path
from oclp.dagster import oclp_artifact_io_manager
resources = {
"oclp_io": oclp_artifact_io_manager(
catalog_path=Path("data/oclp/catalog.duckdb"),
storage_root=Path("data/dagster-artifact-handles"),
)
}
Set io_manager_key="oclp_io" on a single-output @dg.asset, and on every
dg.AssetOut of a @dg.multi_asset. The manager persists a small Artifact UUID
pointer per asset partition, then a later worker reloads the exact handle from
the OCLP catalog and verifies payload reads normally. Those pointers are
reconstructible scheduler state; immutable OCLP records and payloads remain
authoritative.
How to use partitions and dynamic fan-out¶
Partitions belong on the native Dagster asset, just like any other Dagster asset option:
cycles = dg.DynamicPartitionsDefinition(name="training_cycle")
@dg.asset(
key="prepared_orders",
partitions_def=cycles,
io_manager_key="oclp_io",
required_resource_keys={"oclp"},
)
@computation(
name="Prepare orders",
outputs={"prepared": JsonArtifact(name="Prepared orders")},
adapters=(dagster_adapter(),),
)
def prepare_orders() -> dict[str, object]:
return {"rows": 42}
Dynamic partitions often execute in separate processes and scheduler runs, so
do not pass in-memory values between them. Pass Artifact handles through the
catalog I/O manager. Application code may accept an optional context argument
when it genuinely needs a partition value; the adapter injects the native
Dagster execution context without making it an OCLP parameter.
How to declare and assemble ArtifactSets¶
Put @artifact_set outside each canonical producer Computation to declare the
release role of that output. A final native asset accepts the exact producer
handles and uses @assemble_artifact_set to publish the complete cross-run
set:
from oclp import ArtifactHandle, artifact_set, assemble_artifact_set
@dg.asset(
key="prepared_orders",
io_manager_key="oclp_io",
required_resource_keys={"oclp"},
)
@artifact_set(
name="Customer-orders release",
output_port="features",
role="training-data",
)
@computation(
name="Prepare orders",
outputs={"features": JsonArtifact(name="Prepared orders")},
adapters=(dagster_adapter(),),
)
def prepare_orders() -> dict[str, object]:
return {"rows": 42}
@dg.asset(
key="candidate_model",
ins={"features": dg.AssetIn(key=dg.AssetKey("prepared_orders"))},
io_manager_key="oclp_io",
required_resource_keys={"oclp"},
)
@artifact_set(
name="Customer-orders release",
output_port="model",
role="model",
)
@computation(
name="Train order model",
inputs={"features": JsonArtifact},
outputs={"model": JsonArtifact(name="Order model")},
adapters=(dagster_adapter(),),
)
def train_order_model(features: dict[str, object]) -> dict[str, object]:
return {"algorithm": "example"}
@dg.asset(
key="model_release",
ins={
"features": dg.AssetIn(key=dg.AssetKey("prepared_orders")),
"model": dg.AssetIn(key=dg.AssetKey("candidate_model")),
},
required_resource_keys={"oclp"},
)
@assemble_artifact_set(
name="Customer-orders release",
members={
"features": ("features", "training-data"),
"model": ("model", "model"),
},
adapters=(dagster_adapter(),),
)
def model_release(features: ArtifactHandle, model: ArtifactHandle) -> None:
pass
Dagster assets do not share an active OclpRun: preparation, dynamic folds,
and aggregation can execute in distinct processes and scheduler runs. The
adapter therefore defers the producer @artifact_set declarations; publishing
either partial contribution as a completed release would be incorrect. The
terminal asset reloads the durable handles and @assemble_artifact_set
publishes the one complete ArtifactSet.
The decorators share release intent but have different jobs:
@artifact_setis reusable producer metadata for outputs within one shared OCLP run.@assemble_artifact_setis the explicit cross-run publication boundary.
The latter creates a real OCLP ArtifactSet, not a synthetic Artifact,
Computation, or Execution. Its outer Dagster asset is a terminal orchestration
node, not a data value for an I/O manager.
How to group separate Dagster runs with a lifecycle¶
lifecycle is an opt-in portable UUID that groups records across separate
scheduler runs. Configure it on the shared resource when it applies broadly,
or override one declaration:
from oclp import JsonArtifact, computation, lifecycle_from_id
@dg.asset(
key="candidate_model",
required_resource_keys={"oclp"},
)
@computation(
name="Train candidate",
outputs={"model": JsonArtifact(name="Candidate model")},
adapters=(
dagster_adapter(
lifecycle=lambda context: lifecycle_from_id(context.partition_key)
),
),
)
def train_candidate() -> dict[str, object]:
return {"algorithm": "example"}
Matching lifecycle IDs are a grouping assertion only. They do not introduce Dagster dependencies or OCLP dataflow. Use the same lifecycle later for release-backed inference when that work belongs to the same application cycle.
How to attach application profiles¶
Use profiles= on oclp_dagster_resource(...) or dagster_adapter(...) for
application-owned facts such as an MLflow parent run ID. The SDK reserves the
run and dagster profile names:
@dg.asset(key="candidate_model", required_resource_keys={"oclp"})
@computation(
name="Train candidate",
outputs={"model": JsonArtifact(name="Candidate model")},
adapters=(
dagster_adapter(
profiles=lambda context: {
"my_application.mlflow_parent": {
"version": "1",
"mlflow_parent_run_id": parent_run_id_for(context.partition_key),
}
}
),
),
)
def train_candidate() -> dict[str, object]:
return {"algorithm": "example"}