Patterns & Recipes¶
A collection of practical patterns for building real-world pipelines with yieldgraph.
Fan-out — multiple branches from one source¶
Call add_chain a second time to attach a new chain to the same source node. Both chains receive every item the source produces.
from yieldgraph import Graph
def source(graph):
for record in [
{"product": "Widget", "qty": 5, "price": 9.99},
{"product": "Gadget", "qty": 2, "price": 49.99},
{"product": "Doohickey", "qty": 12, "price": 2.49},
]:
yield record
def save_to_database(record):
print(f" DB ← {record['product']}")
yield record["product"]
def send_to_warehouse(record):
print(f" WH ← {record['product']} x{record['qty']}")
yield (record["product"], record["qty"])
g = Graph()
g.add_chain(source, save_to_database) # (1)!
g.add_chain(source, send_to_warehouse) # (2)!
g.run()
- First chain: source → database.
- Second chain: same source function — yieldgraph detects the name match and fans the data to both branches.
Function identity
yieldgraph uses the function's __name__ as the node key. Two different functions with the same name will be treated as the same node. Use distinct function names when building fan-out pipelines.
Attaching a chain to an existing node¶
Use attach_to to splice a new branch into the output of any already-registered node — not just the source:
def source(graph):
for x in range(1, 6):
yield x
def validate(x):
if x % 2 == 0:
yield x # only pass even numbers
def store(x):
yield f"stored:{x}"
def audit_log(x):
yield f"audit:{x}"
g = Graph()
g.add_chain(source, validate, store)
# Tap into validate's output for a parallel audit branch
g.add_chain(audit_log, attach_to="validate") # (1)!
g.run()
print("store output:", [row[0] for row in g.output if "stored" in row[0]])
print("audit output:", [row[0] for row in g.output if "audit" in row[0]])
# store output: ['stored:2', 'stored:4']
# audit output: ['audit:2', 'audit:4']
attach_toaccepts the function name (string) of the node whose output you want to tap.
Seeding a pipeline with initial data¶
By default the first node receives only the Graph instance. Use initial_input to pass extra seed values:
def process(graph, config): # (1)!
for item in config["items"]:
yield item
config = {"items": ["a", "b", "c"]}
g = Graph()
g.add_chain(process, initial_input=(config,)) # (2)!
g.run()
print(g.output)
# [('a',), ('b',), ('c',)]
- The graph instance is always prepended automatically — you only need to list the extra arguments.
initial_inputmust be a tuple.
Error handling¶
Exceptions raised inside a node's job function are caught and collected — they do not crash the pipeline:
from yieldgraph import Graph
def source(graph):
for x in [2, 0, 4, "bad", 8]:
yield x
def safe_divide(x):
result = 100 / x # (1)!
yield result
g = Graph()
g.add_chain(source, safe_divide)
g.run()
# Inspect per-node errors
for name, node in g.nodes.items():
if node.n_errors:
print(f"{name}: {node.n_errors} error(s)")
for err in node.errors:
print(f" → {type(err).__name__}: {err}")
# Still get the successful results
print(g.output)
- Division by zero and
TypeError(from the string"bad") are caught, stored innode.errors, and the pipeline continues with the remaining items.
Graph-level vs node-level errors
node.errors— list ofExceptionobjects caught during that node's runnode.n_errors— convenience countg.error— a non-empty string describes graph-level problems (e.g. no input data for a node)g.succeeded—Trueonly when the run completed ANDg.erroris empty
Cooperative cancellation¶
Set graph.cancelled = True at any point — from inside a node function or from an external thread — to stop the pipeline gracefully after the current yield.
Cancelling from inside the pipeline¶
from yieldgraph import Graph
MAX_RESULTS = 3
def source(graph):
for i in range(1, 100):
yield i
def collect(x):
yield x
def early_stop(x):
yield x
# Cancel after we have collected enough
# (check the owning graph's node count as a proxy)
# Better: cancel from the source itself
def source_with_limit(graph):
for i in range(1, 100):
if i > MAX_RESULTS:
graph.cancelled = True # (1)!
return
yield i
g = Graph()
g.add_chain(source_with_limit, collect)
g.run()
print(g.output) # [(1,), (2,), (3,)]
print(g.cancelled) # True
print(g.succeeded) # False (cancelled counts as not succeeded)
- Setting
graph.cancelledinside a node stops all subsequent nodes. The current node exits cleanly after its function returns.
Cancelling from an external thread¶
import threading
from yieldgraph import Graph
def slow_source(graph):
for i in range(1_000_000):
yield i
def process(x):
yield x * 2
g = Graph()
g.add_chain(slow_source, process)
def watchdog():
import time
time.sleep(0.01) # let the pipeline start
g.cancelled = True # signal cancellation
t = threading.Thread(target=watchdog, daemon=True)
t.start()
g.run()
print(f"Processed {len(g.output)} items before cancel")
KeyboardInterrupt¶
Pressing Ctrl+C while the pipeline runs sets graph.cancelled = True automatically. No special handling required.
Progress monitoring¶
Every Node exposes real-time counters that you can poll from a background thread or a UI update loop:
| Attribute | Type | Description |
|---|---|---|
node.n_consumed |
int |
Items pulled from input edges so far |
node.n_produced |
int |
Items pushed to output edges so far |
node.n_queued |
int |
Total items queued at the start of the run |
node.progress |
float |
n_consumed / n_queued, clamped to [0.01, 1.0] |
node.errors |
list |
Exceptions caught during this node's run |
The Graph itself provides aggregated progress helpers:
| Attribute | Type | Description |
|---|---|---|
g.step |
str |
Human-readable label of the currently executing node |
g.progress |
int |
int(100 * current_node.progress) |
g.finished |
bool |
True once run() has returned |
Observer (push model)¶
Instead of polling in a loop you can attach a :class:~yieldgraph.graph.GraphObserver
to receive callbacks at the start and end of each node and after the whole run:
from yieldgraph import Graph, GraphObserver
class LogObserver(GraphObserver):
def on_run_start(self, total_nodes):
print(f"Starting pipeline ({total_nodes} nodes)")
def on_node_start(self, node_name, step, node_index, total_nodes):
print(f" [{node_index}/{total_nodes}] {step} …")
def on_node_end(self, node_name, step, node_index, total_nodes):
print(f" [{node_index}/{total_nodes}] {step} done")
def on_run_end(self, succeeded, error):
if succeeded:
print("Pipeline finished successfully")
else:
print(f"Pipeline failed: {error}")
g = Graph()
g.add_chain(source, transform, load)
g.observer = LogObserver()
g.run()
Subclass GraphObserver and override only the methods you need — every
method has a no-op default, so partial implementations are safe.
Thread safety
In threaded mode (YIELDGRAPH_THREADED=1), on_node_start and
on_node_end are called from worker threads. If your observer
touches shared state (e.g. a GUI widget, a database connection), protect
it with a lock or dispatch to the main thread.
Polling example¶
import threading
from yieldgraph import Graph
def source(graph):
for i in range(1000):
yield i
def slow_transform(x):
import time
time.sleep(0.001)
yield x * 2
g = Graph()
g.add_chain(source, slow_transform)
def progress_reporter():
import time
while not g.finished:
print(f"Step: {g.step!r:30s} progress: {g.progress:3d}%")
time.sleep(0.05)
reporter = threading.Thread(target=progress_reporter, daemon=True)
reporter.start()
g.run()
reporter.join()
print("Done!")
Labelling nodes¶
By default, node labels are derived from the function name by splitting on _ and uppercasing each part:
load_raw_data → "LOAD RAW DATA".
Override with the labels parameter:
g.add_chain(
fetch_records,
clean_data,
persist_results,
labels=("Fetch from API", "Clean & validate", "Write to DB"),
)
Labels appear in g.step during the run and in node._job.label.
Threaded execution¶
Enable concurrent node execution by setting the YIELDGRAPH_THREADED environment variable before the run:
In threaded mode:
- All nodes start simultaneously as daemon threads.
- Each node blocks on
edge.get()until upstream data arrives. - When a node finishes it closes its output edges, unblocking downstream nodes.
- Edges are automatically thread-safe —
put/getuse aConditionvariable internally.
When to use threaded mode¶
| Sequential (default) | Threaded |
|---|---|
| CPU-bound transforms | I/O-bound nodes (network, disk) |
| Simple linear chains | Long pipelines with independent stages |
| Easier to debug | Better throughput for slow I/O steps |
Thread safety in your code
yieldgraph's Edge queues are thread-safe, but any shared state inside your own functions (e.g. a global counter, a shared file handle) must be protected with a lock.
Thread-safe fan-out¶
In threaded mode each branch of a fan-out pipeline gets its own Edge instance, so branches do not interfere:
os.environ["YIELDGRAPH_THREADED"] = "1"
def source(graph):
for x in range(10):
yield x
def branch_a(x):
yield f"A:{x}"
def branch_b(x):
yield f"B:{x}"
g = Graph()
g.add_chain(source, branch_a)
g.add_chain(source, branch_b)
g.run()
Reusing a graph¶
A Graph instance can be run multiple times. Each call to run() resets all node counters and clears the output cache:
g = Graph()
g.add_chain(source, transform)
g.run()
first_output = list(g.output)
# Mutate source data, re-run
g.run()
second_output = list(g.output)
Note
edges and nodes are not cleared between runs — only per-run counters (n_consumed, n_produced, errors) are reset by Node.reset().
Using graph.output as a lookup table¶
Because graph.output is a plain list, you can use it as a lookup or pass it into a subsequent pipeline:
# Run stage 1
g1 = Graph()
g1.add_chain(extract_ids, fetch_details)
g1.run()
# Pass stage 1 results into stage 2
processed_ids = {row[0] for row in g1.output}
def filter_new(graph):
for record in all_records:
if record["id"] not in processed_ids:
yield record
g2 = Graph()
g2.add_chain(filter_new, enrich, save)
g2.run()
Async / asyncio — not supported¶
yieldgraph job functions must be synchronous. async def functions
and async generators (async def + yield) are not supported.
Why¶
The inner execution loop in Node._run_one uses a plain for statement
to iterate over each job's output:
Passing an async def generator here means the for loop never calls
__anext__, so the coroutine is never scheduled and the node produces
zero output (the error is caught, logged as a node warning, and
execution continues).
Passing a plain async def function (non-generator) is worse: _as_generator
wraps it in a sync generator that returns the coroutine object as a
value — it is never awaited, and that object propagates as unexpected
output to the next node.
What to do instead¶
For I/O-bound concurrency (network calls, database queries, file I/O),
enable threaded execution. Each node runs in its own thread, so blocking
I/O inside a regular generator is fine and gives you the same throughput
benefit as asyncio:
import os, requests
os.environ["YIELDGRAPH_THREADED"] = "1"
def fetch_url(url): # plain sync generator — fine in threaded mode
response = requests.get(url, timeout=10)
yield response.json()
g = Graph()
g.add_chain(source_urls, fetch_url, parse_response)
g.run()
If you are already inside an asyncio event loop and need to call
g.run(), wrap it in asyncio.get_event_loop().run_in_executor(None,
g.run) to avoid blocking the loop.
Warning
There are no plans to add a native Graph.run_async() at this time.
If you have a concrete use-case, please open an issue.