Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Build a Pipeline

How to create a pipeline, wire steps together, and run it to completion.

Prerequisites: Operations Model and at least one operation type.

Key types: PipelineManager, StepResult, StepFuture, OutputReference, CompositeDefinition, FailurePolicy, CachePolicy


Minimal working example

A complete pipeline that generates data, transforms it, and computes metrics:

from artisan.orchestration import PipelineManager
from artisan.operations.examples import DataGenerator, DataTransformer, MetricCalculator

pipeline = PipelineManager.create(
    name="quickstart",
    delta_root="runs/delta",
    staging_root="runs/staging",
)
output = pipeline.output

pipeline.run(operation=DataGenerator, name="generate", params={"count": 5})
pipeline.run(
    operation=DataTransformer,
    name="transform",
    inputs={"dataset": output("generate", "datasets")},
)
pipeline.run(
    operation=MetricCalculator,
    name="metrics",
    inputs={"dataset": output("transform", "dataset")},
)
summary = pipeline.finalize()

The rest of this guide breaks down each piece.


Create a pipeline

pipeline = PipelineManager.create(
    name="my_pipeline",
    delta_root="runs/delta",
    staging_root="runs/staging",
)
ParameterTypeDefaultDescription
namestrPipeline identifier (used in logging and run IDs)
delta_rootPath | strWhere Delta Lake tables are written
staging_rootPath | strWhere workers write intermediate files before commit
working_rootPath | str | Nonetempfile.gettempdir()Worker sandbox directory. Defaults to $TMPDIR
failure_policyFailurePolicyCONTINUEHow to handle step failures (CONTINUE or FAIL_FAST)
cache_policyCachePolicyALL_SUCCEEDEDWhen completed steps qualify as cache hits (ALL_SUCCEEDED or STEP_COMPLETED)
backendstr | BackendBase"local"Default execution backend. Accepts an instance or string name ("local", "slurm", "slurm_intra")
preserve_stagingboolFalseKeep staging files after commit (debugging)
preserve_workingboolFalseKeep worker sandboxes after execution (debugging)
recover_stagingboolTrueCommit leftover staging files from prior crashed runs at init

Both delta_root and staging_root are created automatically if they do not exist. For production SLURM runs, omit working_root — the default uses node-local scratch, which avoids shared filesystem contention.


Add steps

Every step calls pipeline.run() with an operation class. There are three patterns depending on whether the step has inputs.

Source (no inputs)

A generative step creates artifacts from nothing:

pipeline.run(operation=DataGenerator, name="generate", params={"count": 10})

Sequential (one input)

Wire the output of one step to the input of the next using output():

output = pipeline.output

pipeline.run(
    operation=DataTransformer,
    name="transform",
    inputs={"dataset": output("generate", "datasets")},
    params={"scale_factor": 2.0},
)

output("generate", "datasets") returns an OutputReference — a lazy pointer resolved to concrete artifact IDs at dispatch time. The dict key ("dataset") must match the downstream operation’s input role name.

Ingest (external files)

Bring files from disk into the pipeline as artifacts:

from artisan.operations.curator import IngestData

pipeline.run(operation=IngestData, name="ingest", inputs=["/data/a.csv", "/data/b.csv"])

Raw file paths are auto-promoted to FileRefArtifact and committed to Delta Lake before the operation runs. The output role for IngestData is "data".


Name your steps

By default, steps are named after the operation. Pass name= to give a step a custom name, then use output() to reference it later:

output = pipeline.output

pipeline.run(operation=DataGenerator, name="gen", params={"count": 10})
pipeline.run(
    operation=DataTransformer,
    name="transform",
    inputs={"dataset": output("gen", "datasets")},
)

output(name, role) returns an OutputReference — a lazy pointer resolved to concrete artifact IDs at dispatch time. Bind it once after creating the pipeline with output = pipeline.output for concise wiring throughout.

When a pipeline contains multiple steps with the same name, output() returns the most recent one by default. To reference a specific instance, pass step_number:

output("gen", "datasets", step_number=0)

Finalize

finalize() waits for all pending futures, shuts down the executor, and returns a summary dict:

summary = pipeline.finalize()
print(summary["pipeline_name"])     # "my_pipeline"
print(summary["total_steps"])       # 3
print(summary["overall_success"])   # True

finalize() is required when using submit() (see below). With run() only, it is optional but still recommended — it produces the summary and cleans up the executor.


Common patterns

run() vs submit()

Both accept the same parameters. The difference is blocking behavior:

run()submit()
ReturnsStepResult (blocks until done)StepFuture (returns immediately)
Wiring downstreamstep.output("role")future.output("role") — works identically
Use whenSteps must complete before continuingSteps can overlap
finalize()OptionalRequired — waits for all futures
output = pipeline.output
pipeline.submit(operation=DataGenerator, name="generate", params={"count": 100})
pipeline.submit(
    operation=DataTransformer,
    name="transform",
    inputs={"dataset": output("generate", "datasets")},
)
summary = pipeline.finalize()

Step-level overrides

Both run() and submit() accept override parameters beyond operation, inputs, params, and name:

ParameterPurpose
backendOverride the pipeline’s default compute backend for this step
resourcesOverride resource allocation (CPUs, memory, GPUs, time limit)
executionOverride batching settings (artifacts_per_unit, max_workers)
environmentOverride the operation’s runtime environment
toolOverride the operation’s external tool configuration
failure_policyOverride the pipeline’s failure policy for this step
compactRun Delta Lake compaction after commit (default True)

See Configuring Execution for details on each.

Branching (parallel paths)

Feed the same output into multiple independent steps:

output = pipeline.output
pipeline.run(operation=DataGenerator, name="generate", params={"count": 10})
pipeline.submit(operation=TransformA, name="branch_a", inputs={"data": output("generate", "datasets")})
pipeline.submit(operation=TransformB, name="branch_b", inputs={"data": output("generate", "datasets")})

Merging branches

Combine multiple streams into one with Merge:

from artisan.operations.curator import Merge

pipeline.run(
    operation=Merge,
    name="merge",
    inputs=[output("branch_a", "result"), output("branch_b", "result")],
)
# Downstream uses: output("merge", "merged")

Pass inputs as a list. The merged output role is always "merged".

Filtering by metrics

Use Filter to keep artifacts that meet criteria:

from artisan.operations.curator import Filter

pipeline.run(
    operation=Filter,
    name="filter",
    inputs={"passthrough": output("transform", "dataset")},
    params={
        "criteria": [
            {"metric": "distribution.median", "operator": "gt", "value": 0.5},
        ],
    },
)
# Downstream uses: output("filter", "passthrough")

Composing operations with composites

A composite groups multiple operations into a reusable unit. Define one by subclassing CompositeDefinition and implementing compose():

from enum import StrEnum
from typing import ClassVar

from artisan.composites import CompositeDefinition, CompositeContext
from artisan.schemas.specs.input_spec import InputSpec
from artisan.schemas.specs.output_spec import OutputSpec

class TransformAndScore(CompositeDefinition):
    """Transform data then compute metrics."""

    name = "transform_and_score"

    class InputRole(StrEnum):
        DATASET = "dataset"

    class OutputRole(StrEnum):
        METRICS = "metrics"

    inputs: ClassVar[dict[str, InputSpec]] = {
        InputRole.DATASET: InputSpec(artifact_type="data", required=True),
    }
    outputs: ClassVar[dict[str, OutputSpec]] = {
        OutputRole.METRICS: OutputSpec(artifact_type="metric"),
    }

    def compose(self, ctx: CompositeContext) -> None:
        transformed = ctx.run(
            DataTransformer,
            inputs={"dataset": ctx.input("dataset")},
            params={"scale_factor": 2.0},
        )
        scored = ctx.run(
            MetricCalculator,
            inputs={"dataset": transformed.output("dataset")},
        )
        ctx.output("metrics", scored.output("metrics"))

Use pipeline.run() for collapsed execution (single step, in-memory artifact passing) or pipeline.expand() for expanded execution (each internal operation becomes its own pipeline step):

output = pipeline.output
pipeline.run(operation=DataGenerator, name="gen", params={"count": 10})

# Collapsed — single step
pipeline.run(operation=TransformAndScore, name="ts",
             inputs={"dataset": output("gen", "datasets")})

# Expanded — each internal operation is a separate step
pipeline.expand(composite=TransformAndScore, name="ts",
                inputs={"dataset": output("gen", "datasets")})

For the full guide on writing composites, see Writing Composite Operations.

Intermediates handling controls what happens to artifacts produced by operations before the final one:

ModeBehavior
"discard" (default)Intermediates discarded after the composite completes
"persist"Intermediates committed to Delta Lake with internal provenance edges
"expose"Like "persist", but execution edges include intermediate outputs

SLURM execution

Dispatch a step to SLURM:

from artisan.orchestration import Backend

pipeline.run(
    operation=DataTransformer,
    name="transform",
    inputs={"dataset": output("generate", "datasets")},
    backend=Backend.SLURM,
    resources={"gpus": 1, "memory_gb": 16, "extra": {"partition": "gpu"}},
)

See Configuring Execution for the full list of resource and batching options.

Resume a previous run

Re-running a pipeline skips steps with matching inputs and parameters (content-addressed caching). To continue a run that failed partway through:

pipeline = PipelineManager.resume(
    delta_root="runs/delta",
    staging_root="runs/staging",
)

resume() reconstructs step results from Delta Lake and sets the step counter so new steps continue the sequence. Pass pipeline_run_id="..." to resume a specific run; omit it to resume the most recent. Pass name="..." to override the pipeline name.

List previous runs

Inspect all pipeline runs stored in a delta root:

runs = PipelineManager.list_runs(delta_root="runs/delta")
print(runs)  # polars DataFrame with run IDs, step counts, and timestamps

Common pitfalls

ProblemCauseFix
Output role 'X' not availableMismatched role name in .output()Check the operation’s output role names
Downstream step receives 0 artifactsUpstream step failed or filtered everything outCheck step.success and step.succeeded_count
Raw file paths are not allowed for creator operationsPassed a file path list to a creator operationUse IngestData first, then wire its output
Pipeline hangs on exitForgot finalize() after using submit()Call pipeline.finalize()
Stale results after code changeContent-addressed cache hit from a previous runUse a fresh delta_root

Verify

Run your pipeline with a small dataset to confirm wiring and output:

pipeline = PipelineManager.create(
    name="test", delta_root="test/delta", staging_root="test/staging",
)
step = pipeline.run(operation=DataGenerator, params={"count": 3})
assert step.success
assert step.succeeded_count == 3

Cross-references