Pipelines & execution¶
A pipeline is an ordered chain of steps. This page covers the two halves of getting one
to run: building it with >> and the check compile() runs before anything
executes, then running it — every control run() accepts, how checkpoints work, and
what to reach for when a long run stops halfway.
Building a pipeline¶
>> joins steps into a pipeline:
from datafast import Map, Sink, Source
pipeline = (
Source.list([{"text": "hello"}, {"text": "world"}])
>> Map(lambda record: {**record, "length": len(record["text"])})
>> Sink.jsonl("out.jsonl")
)
The chain is linear. A pipeline is itself a step, so you can build one in pieces and join the pieces:
from datafast import Map, Source
prepare = Source.list([{"text": "hello"}]) >> Map(lambda r: {**r, "n": 1})
finish = Map(lambda r: {**r, "n": r["n"] + 1})
pipeline = prepare >> finish
print(len(pipeline.steps)) # 3 — the parts are flattened, not nested
Give a step a name with as_step. The name shows up in logs, in checkpoint file names,
and is what resume_from and stop_after match on:
from datafast import Map
step = Map(lambda record: record).as_step("clean_text")
print(step.name) # clean_text
Without a name, a step is called after its class — Map, LLMStep, ListSource. Two
unnamed steps of the same class share a name, which is legal but makes logs harder to
read.
What compile() checks¶
Pipeline.compile() validates the pipeline without running it. run() calls it for
you, so you rarely call it yourself — but calling it directly is a free way to catch a
mistake before you spend anything on LLM calls:
from datafast import Map, Sink, Source
pipeline = Source.list([{"a": 1}]) >> Map(lambda r: r) >> Sink.jsonl("out.jsonl")
pipeline.compile() # returns the pipeline, so you can chain .run()
It raises PipelineValidationError on the first problem it finds, so fixing one
error can reveal the next. Here is what it looks for:
| Rule | Example message |
|---|---|
| A pipeline starts with a source | Pipeline must start with a source; step 0 is 'Map' (Map). |
| Only the first step may be a source | Source 'ListSource' at position 1 discards upstream records; a source may only be the first step. |
| Nothing may follow the sinks | Step 'Map' at position 2 comes after the sink at position 1; sinks must be the last steps. |
Every Branch is closed by a JoinBranches |
Branch at position 1 is never closed by a JoinBranches. |
Every JoinBranches has a Branch |
JoinBranches at position 1 has no matching Branch. |
| A step reads only columns that exist | Step 'LLMStep' references column(s) ['b'] that are not available. Available columns: ['a']. |
Two rules read as if they were one but are not. Several sinks may be chained — they pass their records through, so writing the same dataset to a file and to the Hub is one run. What is rejected is a step after a sink.
Column checking is deliberately partial¶
compile() tracks the columns a record has, starting from what the source declares. Some
steps reshape records in ways it cannot predict — Map, FlatMap, Group, Pair,
Join, Concat, Branch and the specialized LLM steps. After one of those, the schema
becomes unknown and column checking stops for the rest of the pipeline.
This is on purpose: a missed error is an inconvenience, but a wrong error would stop a
correct pipeline from running. So treat a clean compile() as "no mistake found", not
"no mistake possible".
Sub-pipelines¶
A Branch path, a Concat source and a Join right side each hold their own chain of
steps, and compile() checks inside them too. The rules differ by what feeds them:
| Sub-pipeline | Gets records from | Must start with a source | May contain a sink |
|---|---|---|---|
Branch path |
the records entering the branch | no — a source would throw them away | no |
Concat source |
nothing | yes | no |
Join right side |
nothing | yes | no |
A Branch inside a branch path is rejected outright. The inner branch would overwrite
the outer branch's metadata, and JoinBranches would then drop every record. Close the
outer branch first.
Running a pipeline¶
run() returns every record from the last step as a list. Sinks pass records through, so
a pipeline that writes to a file still returns its records.
The runner executes one step at a time and materializes the whole result before starting the next step. That is what makes checkpointing, resume and LLM batching possible: each step has a clean boundary where the full set of records exists.
Run controls¶
| Parameter | Type | Default | What it does |
|---|---|---|---|
checkpoint_dir |
str \| None |
None |
Directory for checkpoint files. None disables checkpointing. |
resume |
bool |
False |
Continue from an existing checkpoint instead of starting over. |
resume_from |
str \| None |
None |
Re-run from this step name, discarding it and every later step. |
stop_after |
int \| str \| None |
None |
Stop once this step finishes. A step index or a step name. |
limit |
int \| None |
None |
Keep only the first N records from the source. |
batch_size |
int |
4 |
LLM calls per batch. |
llm_strategy |
str |
"by_model" |
Order of LLM calls: "by_model", "round_robin" or "by_record". |
checkpoint_every |
int |
100 |
Save an LLM step's progress file every N completed calls. Resume needs the first one. |
limit applies after the source has read everything. It truncates the source's
output, so a 100,000-row file is still read in full and then cut to N. It is a way to
keep a trial run cheap in LLM calls, not a way to read less from disk.
stop_after accepts either form:
pipeline.run(stop_after=1) # after the step at index 1
pipeline.run(stop_after="clean_text") # after the step named clean_text
A name that matches no step, or an index outside the pipeline, raises ValueError before
the first step runs — the same treatment resume_from gives a name it cannot find.
RunConfig and run_pipeline¶
run() is a thin wrapper. It packs its arguments into a RunConfig and hands it to the
runner. The eight controls above are exactly the fields of RunConfig — there is no
hidden ninth. You can build one yourself when you want to reuse a configuration:
from datafast import Map, RunConfig, Runner, Source
pipeline = Source.list([{"a": 1}]) >> Map(lambda record: record)
config = RunConfig(batch_size=8, llm_strategy="round_robin")
records = Runner(pipeline, config).execute()
run_pipeline(pipeline, ...) is the same thing as a function, and takes the same
arguments as run(). Note that run() and run_pipeline() call compile() for you,
while constructing a Runner directly does not.
Anything run() does not name in its own signature is passed on to RunConfig, so
checkpoint_every works as a keyword — and a typo raises TypeError immediately rather
than being ignored.
Execution strategies¶
When one LLM step has several served models, llm_strategy decides the order the calls
go out in. With three records and two models:
| Strategy | Order of calls |
|---|---|
by_model (default) |
all of model A, then all of model B |
round_robin |
A, B, A, B, A, B |
by_record |
record 1 on every model, then record 2, ... |
round_robin and by_record produce the same order in the common case, and differ only
when the number of calls per record is uneven.
The order is not cosmetic. Records come out in the order their calls completed, so the
strategy is also the order of the output list. by_model groups every row from one model
together; by_record keeps each record's rows side by side, which is easier to eyeball
when you are comparing models.
by_model is the default because it sends consecutive calls to the same provider, which
batches better and keeps one provider's rate limit in one place.
Checkpoints and resume¶
Set checkpoint_dir and the runner writes its progress to disk:
The directory then holds one JSONL file per step plus a manifest:
manifest.json records the state of the run: each step's index, name, status
(pending, in_progress or complete) and record counts, plus a fingerprint of the
pipeline.
Run again with resume=True and the runner picks up at the first step that is not
complete, loading the previous step's records from disk:
If every step is already complete, resume=True returns the saved records without
running anything.
Resuming inside an LLM step¶
LLM steps checkpoint per call, not per step. Each completed call's record and its call
id are written as soon as the call returns. The step also saves a progress file every
checkpoint_every calls, and resume needs that file to exist.
Once the first progress file exists, resume is exact. A run that dies after 700 of 1,000 calls resumes at call 701, so you do not pay for the first 700 again. A run that dies before the first progress file, at call 60 on the default of 100, runs the whole step again.
Lower checkpoint_every for slow or expensive steps, so the first progress file comes
sooner. Raise it for cheap, fast steps, where writing the progress file costs more than
the calls.
When the checkpoint no longer matches¶
The fingerprint covers the structure of the pipeline: each step's name and class, and
the same information for anything nested inside a Branch path.
- If the structure changed and
resume=True, the run raisesPipelineChangedErrorrather than mixing results from two different pipelines. - If the structure changed and
resume=False, the runner clears the old checkpoint and starts fresh, with a warning in the log.
The fingerprint does not cover what the steps actually do. Changing a prompt, a
served model, a temperature or the function inside a Map leaves it identical, so resume
will happily continue a run under the new settings and nothing warns you.
That makes one rule important: a step must produce the same records for the same input
every time it runs. Calls are matched to records by position, so a step that shuffles,
samples randomly or reads a changing file can attach a resumed LLM result to the wrong
record. If you changed what a step does, either start fresh or use resume_from.
Two more things worth knowing before you point checkpoint_dir somewhere:
- Give each pipeline its own directory. Clearing a stale checkpoint deletes every file in that directory, not only the ones datafast wrote.
- Naming steps makes checkpoints readable. Files are named
step_<index>_<step name>.jsonl, so two unnamedMapsteps give youstep_001_Map.jsonlandstep_002_Map.jsonl.
Re-running one step: resume_from¶
resume_from names a step to redo. That step and every step after it are discarded;
everything before it is reused from the checkpoint:
This is the tool for "the generation was fine, the scoring prompt was wrong". It needs
checkpoint_dir and an existing checkpoint, and raises ValueError if the name is not a
step in the pipeline — the message lists the names it does know.
Throughput lives on the served model¶
Nothing on the runner controls how fast you call a provider. Rate limiting, concurrency, timeouts and retries are configured on the served model:
| Setting | Default | What it does |
|---|---|---|
rpm_limit |
None |
Requests per minute. The model waits rather than exceeding it. |
max_concurrent |
4 |
Calls made in parallel inside one batch. |
timeout |
None |
Seconds to wait for one response. |
retry_limit |
None → 3 |
Retries after a failed call. |
from datafast import openai
model = openai(model_id="gpt-6-luna", rpm_limit=500, max_concurrent=8, timeout=60)
This split is deliberate. A rate limit belongs to the provider's account, not to a step: if two steps share one served model, they must share one limit, and a limit set per run could not do that.
batch_size and max_concurrent are easy to confuse. batch_size is how many calls the
runner hands over at a time; max_concurrent is how many of those actually run in
parallel. Setting batch_size=64 with max_concurrent=4 still makes four calls at a
time — it only means the runner checkpoints and logs less often.
Things worth knowing¶
compile()runs automatically.run()calls it first, so an invalid pipeline fails before it spends anything.- Steps are materialized, not streamed. Every record from a step exists in memory before the next step starts. That is what resume and batching are built on, and it is also the memory ceiling on a very large run.
limittruncates, it does not skip reading. The source still loads everything.- A clean
compile()is not a proof. Column checking stops after any step that reshapes records opaquely. - Resume trusts you on determinism. The fingerprint sees structure, not behaviour.
run()returns records even when a sink wrote them. Sinks pass records through.
Where to go next¶
- Key Concepts — the record → step → pipeline → runner model.
- Data ops — the transforms and sinks you chain together.
- LLM step — the steps that make the calls this page schedules.
- Branching —
BranchandJoinBranchesin full. - Served models — every rate limit, timeout and retry setting.
- Full Glossary — the exact meaning of step, runner, checkpoint and manifest.