Tutorial: from one task to a resumable model evaluation¶
English | 简体中文
A step-by-step guide to using pyattacker, from a five-line program to a resumable, sharded model evaluation.
Who it is for: you run LLM or agent evaluations in Python, you know asyncio, and you want the
infrastructure part — endpoint pools, concurrency, retries, "which rows already ran", a record of every
request.
How to read it:
- Every step is a complete program. Run it, read the output, then read the notes.
- Code blocks starting with
# tutorial/step_NN_....pyare extracted from this file and executed bytests/test_tutorial.pyon every test run. Save one as the file named in the comment and run it directly. - Nothing here touches the network. Where a real program would
await self.http.post(...), these examplesawait asyncio.sleep(...)— the comment# <- your HTTP callmarks the spot.
Other documents: docs/reference.md documents every class and function;
docs/cli.md covers the command line; docs/design.md explains why the framework
is shaped this way; examples/ holds complete programs.
Find what you need¶
Read the steps in order the first time. Afterwards, use this table.
| I want to… | Step | Reference |
|---|---|---|
| write my first task and run it | Step 1 | Tasks |
| run a whole dataset, or k samples per row | Step 2 | map |
| chain several steps together | Step 3 | Pipelines |
| understand what gets saved, and when | Step 4 | Artifacts |
| spread load over several endpoints or keys | Step 5 | Resources |
| choose how to wait when everything is busy | Step 6 | Algorithms |
| use a resource safely | Step 7 | Lease |
| retry failures, and see why something gave up | Step 8 | Retrying, Errors |
| pick up after a crash without re-paying for work | Step 9 | Runner |
| query the record, export results | Step 10 | Stores, Export |
| drive it from YAML and the CLI | Step 11 | docs/cli.md |
| use more than one process | Step 12 | Sharding |
| branch inside a step | Step 13 | fanout |
| store custom types or large payloads, ship a plugin | Step 14 | Codecs, Backends, Plugins |
| monitor a run in progress | Step 14 | Monitoring |
| skip the rest of a chain from inside a task | Step 15 | Handoffs |
| send work back to an earlier station | Step 16 | Backward traversal |
| snapshot and restore state inside a payload | Step 17 | HistoryArtifact |
Step 0 — install and sanity-check¶
uv sync # or: python -m venv .venv && .venv/bin/pip install -e .
uv run pyattacker demo # zero-config smoke test: 50 simulated pipelines, retries, a report
uv run pytest -q # the whole suite, offline, a few seconds
pyattacker demo is the fastest way to see the shape of a run:
Requirements: Python 3.11+ and nothing else — the base install has no dependencies. Step 11 reads a YAML
config, the one thing that needs the optional extra (uv add "pyattacker[yaml]"); every other step installs
nothing.
Step 1 — one seed, one task, one run¶
# tutorial/step_01_smallest.py
"""Step 1 - the smallest useful program: one seed, one task, one persisted result."""
from pyattacker import Runner, pipeline, task
@task("double")
def double(seed: dict) -> dict:
"""A task is a unary function: one artifact in, one artifact out."""
return {"n": seed["n"], "doubled": seed["n"] * 2}
template = pipeline("doubling", double)
with Runner(store="runs/step01.db", concurrency=2, label="step-1") as runner:
report = runner.run(template.map([{"n": 21}]))
print(report.summary())
row = next(iter(runner.store.export_rows()))
print(f"pipeline {row['pipeline_id'][:12]} state={row['state']} "
f"tasks={row['n_tasks_done']}/{row['n_tasks_total']}")
print("final artifact:", row["artifacts"][-1]["payload"])
run run-... status=completed wall=0.00s
pipelines: total=1 succeeded=1
pipeline latency ms: p50=0.487 p95=0.487 max=0.487
attempts: total=1
tasks: double=1
pipeline b3265e1db9b8 state=succeeded tasks=1/1
final artifact: {'doubled': 42, 'n': 21}
Five concepts, and they are the whole vocabulary:
| Concept | In this program | What it is |
|---|---|---|
| artifact | {"n": 21, "doubled": 42} |
the persisted output of one task, written as soon as it is produced |
| task | double |
a unary function (artifact) -> artifact; sync or async, no base class |
| pipeline | pipeline("doubling", double) |
a linear chain of tasks; the unit of completion and resume |
| seed | [{"n": 21}] |
one dataset row; template.map(seeds) makes one pipeline per row |
| runner | Runner(store=..., concurrency=2) |
the scheduler: owns the store, the pools and the worker slots |
Three things to know now:
- A task takes one parameter, or two (
value, ctx) when it needs the framework context. Anything else raisesConfigErrorat decoration time. Put extra state in a closure. Runneris a context manager, and closing it closes the store. Read a file-backed store inside thewithblock, or reopen the file later (Step 10). In-memory stores are unaffected.store=":memory:"is the default and is what you want in tests; a path gives you a durable SQLite file.
Step 2 — many inputs at once¶
The pipeline template is reusable: map() turns a stream of seeds into pipelines that are semantically
completely independent of each other.
# tutorial/step_02_map.py
"""Step 2 - one template, many inputs: map() turns a stream of seeds into independent pipelines."""
import asyncio
import time
from pyattacker import Runner, pipeline, task
def dataset(n: int):
"""Any iterable works; a generator keeps memory flat no matter how large the dataset is."""
for i in range(n):
yield {"qid": f"q{i:02d}", "value": i}
@task("square")
async def square(row: dict) -> dict:
await asyncio.sleep(0.02) # <- in real life: one HTTP request
return {"qid": row["qid"], "squared": row["value"] ** 2}
template = pipeline("squares", square)
with Runner(store="runs/step02.db", concurrency=4, label="step-2") as runner:
started = time.perf_counter()
report = runner.run(template.map(dataset(12)))
elapsed = time.perf_counter() - started
print(report.summary())
print(f"12 pipelines x 20ms of work, concurrency=4 -> {elapsed:.2f}s wall clock")
# pass@k: one seed expands into k independent pipelines, each with its own checkpoint.
for spec in template.map([{"qid": "q00", "value": 0}], repeats=3):
print(f" repeat={spec.repeat} key={spec.key[:12]}")
run run-... status=completed wall=0.07s
pipelines: total=12 succeeded=12
pipeline latency ms: p50=20.734 p95=21.868 max=22.085
attempts: total=12
tasks: square=12
12 pipelines x 20ms of work, concurrency=4 -> 0.07s wall clock
repeat=0 key=b8c5e6d11da5
repeat=1 key=418c035f6b74
repeat=2 key=055e9cb3f519
map()takes any iterable, including a generator, and yields lazily. Memory stays at O(concurrency), so a 10-million-row dataset costs the same as a 10-row one.concurrency=4means four attempts in flight, not four pipelines alive — a pipeline waiting out a retry backoff holds no worker slot (Step 8).- Your task body must actually
awaitsomething for work to overlap. 12 × 20 ms at concurrency 4 finishes in ~0.07 s; a task that blocks the event loop would take 0.24 s. repeats=3is pass@k and self-consistency sampling: one seed, three pipelines, three independent checkpoints. Usekey_of=lambda row: row["qid"]to supply your own ids instead of the content-addressed default.spec.key(the same value aspipeline_id) comes from the task chain plus the seed content plus the repeat index, so re-running the same dataset produces the same ids. That is what makes resume and sharding work.
Step 3 — chaining tasks¶
a | b | c builds a pipeline. Each task's artifact is the next task's input, and the chain is validated
when you build it, not halfway through a run.
# tutorial/step_03_chain.py
"""Step 3 - chaining: prepare | answer | judge. Each task's artifact is the next task's input."""
from pyattacker import PipelineBuildError, Runner, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"question": seed["q"], "context": ["doc-1", "doc-2"]}
@task("answer")
def answer(row: dict) -> dict:
return {**row, "answer": f"answer to {row['question']!r}"}
@task("judge")
def judge(row: dict) -> dict:
return {"question": row["question"], "answer": row["answer"], "score": len(row["answer"]) % 5}
@task("expects_int")
def expects_int(value: int) -> int:
return value
# The chain is validated where it is built, not in the middle of a run:
try:
pipeline("broken", prepare | expects_int)
except PipelineBuildError as exc:
print("rejected at construction time:", exc)
template = pipeline("rag", prepare | answer | judge, tags={"example": "step-3"})
with Runner(store="runs/step03.db", concurrency=2, label="step-3") as runner:
report = runner.run(template.map([{"q": "why is the sky blue?"}]))
print(report.summary())
row = next(iter(runner.store.export_rows()))
print("tasks:")
for item in row["tasks"]:
print(f" seq={item['seq']} {item['name']:<8} {item['state']:<9} "
f"attempts={item['attempts_used']} duration_ms={round(item['duration_ms'], 3)}")
print("artifacts (the seed, then one per task):")
for item in row["artifacts"]:
print(f" seq={item['seq']:>2} {item['task']:<10} digest={item['digest'][:12]} "
f"payload={item['payload']!r}")
print("final artifact:", row["artifacts"][-1]["payload"])
rejected at construction time: artifact types do not chain: task 'prepare' produces dict, but task 'expects_int' requires int
run run-... status=completed wall=0.00s
pipelines: total=1 succeeded=1
...
tasks:
seq=0 prepare succeeded attempts=1 duration_ms=0.016
seq=1 answer succeeded attempts=1 duration_ms=0.005
seq=2 judge succeeded attempts=1 duration_ms=0.004
artifacts (the seed, then one per task):
seq=-1 __seed__ digest=ce5d39232b24 payload={'q': 'why is the sky blue?'}
seq= 0 prepare digest=0ad2b84efc93 payload={'context': ['doc-1', 'doc-2'], 'question': 'why is the sky blue?'}
seq= 1 answer digest=c6fbc3132821 payload={'answer': "answer to 'why is the sky blue?'", 'context': [...], 'question': '...'}
seq= 2 judge digest=ffc919adcb7e payload={'answer': "answer to 'why is the sky blue?'", 'question': '...', 'score': 2}
final artifact: {'answer': "answer to 'why is the sky blue?'", 'question': 'why is the sky blue?', 'score': 2}
- Chain checking uses the annotations: a task returning
Questionchains into a task takingQuestion(subclasses are fine),Anyor no annotation is permissive, and a bare container accepts its parameterised form (dict←dict[str, Any]). A mismatch raisesPipelineBuildErrorwhen you build the pipeline. seqis a task's position in the chain.seq=-1is the seed: the dataset row is itself a stored artifact, which is what lets a resumed run work without the original file (Step 9).- The final artifact is the last task's output, marked
is_final=True. - A pipeline is linear. When a step genuinely branches — three judges, k samples, several metrics — keep
the branch inside one task with
fanout(...)(Step 13).
Step 4 — the checkpoint is the artifact¶
Every successful task persists its artifact immediately, so the checkpoint granularity is the task.
# tutorial/step_04_checkpoint.py
"""Step 4 - what a checkpoint is: one content-addressed artifact per task, durable immediately."""
from dataclasses import dataclass
from pyattacker import Runner, pipeline, task
@dataclass
class Answer:
qid: str
text: str
@task("fetch")
def fetch(seed: dict) -> dict:
return {"qid": seed["qid"], "question": seed["question"]}
@task("answer")
def answer(row: dict) -> Answer:
"""An annotated type is registered automatically, so a restored checkpoint is an Answer again."""
return Answer(qid=row["qid"], text=f"answer to {row['question']!r}")
@task("score")
def score(ans: Answer) -> dict:
return {"qid": ans.qid, "score": len(ans.text) % 5}
template = pipeline("qa", fetch | answer | score, tags={"stage": "tutorial"})
rows = [{"qid": "q1", "question": "why?"}, {"qid": "q2", "question": "how?"}]
with Runner(store="runs/step04.db", concurrency=2) as runner:
first = runner.run(template.map(rows))
print("first run: ", first.stats["pipelines"]["by_state"])
# Same seeds + same task source -> the same content-addressed keys -> nothing runs again.
again = runner.run(template.map(rows))
print("second run: skipped =", again.skipped,
"(a skipped pipeline is not rewritten, so it still belongs to the first run)")
row = next(iter(runner.store.export_rows()))
print(f"\npipeline {row['pipeline_id'][:12]} tags={row['tags']}")
for item in row["artifacts"]:
print(f" seq={item['seq']:>2} {item['task']:<10} {item['type']:<7} codec={item['codec']:<5} "
f"digest={item['digest'][:10]} final={item['is_final']} payload={item['payload']!r}")
checkpoint = runner.store.get_artifact(row["pipeline_id"], 1)
restored = runner.registry.load(checkpoint.encoded())
print("checkpoint at seq=1 restores as:", type(restored).__name__, restored)
@task("score")
def score_changed(ans: Answer) -> dict:
"""Same name, different body: the source digest changes, so this is a *different* pipeline."""
return {"qid": ans.qid, "score": len(ans.text) % 7}
changed = pipeline("qa", fetch | answer | score_changed, tags={"stage": "tutorial"})
print("\nchanged task source -> new pipeline:", template.spec_digest != changed.spec_digest)
print("old spec_digest:", template.spec_digest[:16], "new spec_digest:", changed.spec_digest[:16])
first run: {'succeeded': 2}
second run: skipped = 2 (a skipped pipeline is not rewritten, so it still belongs to the first run)
pipeline 46122647cecb tags={'stage': 'tutorial'}
seq=-1 __seed__ dict codec=json digest=354ba03eb6 final=False payload={'qid': 'q1', 'question': 'why?'}
seq= 0 fetch dict codec=json digest=354ba03eb6 final=False payload={'qid': 'q1', 'question': 'why?'}
seq= 1 answer Answer codec=json digest=a14ab1a820 final=False payload={'qid': 'q1', 'text': "answer to 'why?'"}
seq= 2 score dict codec=json digest=29d44d6ed5 final=True payload={'qid': 'q1', 'score': 1}
checkpoint at seq=1 restores as: Answer Answer(qid='q1', text="answer to 'why?'")
changed task source -> new pipeline: True
old spec_digest: 5808fe46061bad55 new spec_digest: 01b60826ed0465ea
For every successful task, four things happen in order: the artifact bytes are written, the task row is
finalised, n_tasks_done advances, and only then does the next task start.
- Content addressing.
seq=-1andseq=0share a digest here becausefetchreturns the seed unchanged — identical payloads are identical bytes, so they cost storage once. An artifact's identity is(pipeline_id, seq). - Dataclasses round-trip. Annotating the return type (
-> Answer) registers the class, so a restored checkpoint is anAnswer, not adict. For binary types, register a codec (Step 14). - Change a task's code, get a new pipeline. The pipeline key includes a digest of each task's source, so
editing a task body abandons the old checkpoints rather than reusing results produced by different code.
Pass
include_code=Falsetopipeline(...)when you want body-only changes to keep reusing them; factory parameters, child tasks and declared policies still affect identity. Explicit keys reject task/input mismatches. See resume identity for v2 store upgrades and idempotency when external work succeeds before a checkpoint becomes durable. - Re-running the same seeds is a no-op. The second run reports
skipped=2. A skipped pipeline is not rewritten, so its row still belongs to the run that did the work.
Step 5 — endpoints as a pool¶
A Resource is one concrete capability (an endpoint, a key, a local worker). A Pool is a group of them plus
a policy for waiting. Tasks get one through ctx.acquire(...).
# tutorial/step_05_pool.py
"""Step 5 - endpoints as a pool: a factory makes a client, capacity bounds concurrency, selectors route."""
import asyncio
from pyattacker import Pool, Resource, Runner, pipeline, task
class Client:
"""The shape your real provider client should have: built from resource.options, owned by the pool."""
def __init__(self, options: dict) -> None:
self.model = options["model"]
async def chat(self, prompt: str) -> str:
await asyncio.sleep(0.01) # <- your HTTP call
return f"[{self.model}] {prompt}"
def build_client(resource: Resource) -> Client:
return Client(resource.options)
@task("ask", resource="apis", timeout_s=5)
async def ask(row: dict, ctx) -> dict:
model = "gpt-4o" if row["hard"] else "gpt-4o-mini"
async with ctx.acquire(model=model) as lease: # only a matching resource is handed out
text = await lease.client.chat(row["question"])
lease.report(ok=True, latency_ms=10, usage={"tokens": len(text)})
return {"question": row["question"], "answer": text, "served_by": lease.resource.id}
pool = Pool(
"apis",
[
Resource.create("llm", id="api-a", capacity=2, options={"model": "gpt-4o"}, factory=build_client),
Resource.create("llm", id="api-b", capacity=2, options={"model": "gpt-4o"}, factory=build_client),
Resource.create("llm", id="api-c", capacity=4, options={"model": "gpt-4o-mini"}, factory=build_client),
],
)
rows = [{"question": f"q{i}", "hard": i % 2 == 0} for i in range(6)]
with Runner(store="runs/step05.db", pools=[pool], concurrency=8) as runner:
report = runner.run(pipeline("qa", ask).map(rows))
print(report.summary())
print("resource state (capacity is per resource, so this pool can hand out 8 leases at once):")
for slot in pool.snapshot():
print(f" {slot['id']:<6} state={slot['state']:<7} leases={slot['leases']} "
f"active_now={slot['active']}/{slot['capacity']}")
stats = pool.stats()
print(f"pool totals: leases={stats.leases_total} ok={stats.ok_total} "
f"waiting={stats.waiting} utilization={stats.utilization}")
print("usage reported through lease.report():", stats.usage)
run run-... status=completed wall=0.02s
pipelines: total=6 succeeded=6
...
resource state (capacity is per resource, so this pool can hand out 8 leases at once):
api-a state=ready leases=2 active_now=0/2
api-b state=ready leases=1 active_now=0/2
api-c state=ready leases=3 active_now=0/4
pool totals: leases=6 ok=6 waiting=0 utilization=0.0
usage reported through lease.report(): {'tokens': 81.0}
capacitybelongs to the resource, not the pool:api-aandapi-ballow 2 concurrent leases each,api-callows 4, so this pool can serve 8 requests at once. Compare that total againstconcurrency— more workers than capacity means workers queueing on the pool.factory=is called once per resource, lazily, on first lease; every lease of that resource then shares the client object. That is where your SDK client or connection pool goes. If the factory raises, the lease is refused withResourceUnavailable(never a lease whoseclientisNone), aresource.factory_failedevent is recorded, and repeated failures eventually mark the resourcedead.- The selector (
ctx.acquire(model="gpt-4o-mini")) matches onoptions,tags,idandkind, including dot paths into nested options ("quota.tokens"). This is how you route to the right client. lease.report(...)is how the pool learns anything:ok=Falsefeeds circuit-breaking,latency_msmaintains an EMA,usage={"tokens": n}accumulates quota you can rank on (Step 6).pool.snapshot()is the per-resource view,pool.stats()the aggregate. Both are safe to call during a run.
Gotcha: with the default
waitalgorithm, a selector that matches no resource blocks forever. Either make sure every selector has a resource, or passtimeout=/ usealgorithm="immediate"so you get an error instead of a hang.
Step 6 — choosing how to wait¶
Acquiring a resource is policy; using it is your code. The algorithm is set per task or per pool.
# tutorial/step_06_algorithms.py
"""Step 6 - how a task waits for a resource is a policy, orthogonal to what the task does."""
import asyncio
from pyattacker import Pool, Resource, Runner, pipeline, task
def make_pool(name: str, algorithm: str, *, resources: int = 1, capacity: int = 1,
options: dict | None = None) -> Pool:
return Pool(
name,
[
Resource.create("llm", id=f"{name}-{i}", capacity=capacity,
options=dict(options or {"model": "m"}))
for i in range(resources)
],
algorithm=algorithm,
)
def experiment(algorithm: str, rows: list, *, resources: int = 1, capacity: int = 1,
concurrency: int = 4):
"""One task body, one pool, four acquire policies."""
@task("ask", resource=f"p-{algorithm}", algorithm=algorithm)
async def ask(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
await asyncio.sleep(0.02) # the request
return {"i": row["i"], "resource": lease.resource.id}
pool = Pool(f"p-{algorithm}", [
Resource.create("llm", id=f"p-{algorithm}-{i}", capacity=capacity, options={"model": "m"})
for i in range(resources)
], algorithm=algorithm)
with Runner(store=f"runs/step06_{algorithm}.db", pools=[pool], concurrency=concurrency) as runner:
report = runner.run(pipeline(f"t-{algorithm}", ask).map(rows))
error_types = sorted({e["error_type"] for e in runner.store.errors()})
return report, pool, error_types
rows4 = [{"i": i} for i in range(4)]
# immediate: fail fast when the pool is saturated (note: ResourceUnavailable is classified "unknown",
# so the default policy does not retry it - add retry_unknown=True if you want capacity retries)
report, _, error_types = experiment("immediate", rows4)
print("immediate :", report.stats["pipelines"]["by_state"], "-> errors:", error_types)
# wait (the default): queue up until a slot frees
report, _, _ = experiment("wait", rows4)
print("wait :", report.stats["pipelines"]["by_state"], "(4 x 20ms of work on a single slot)")
# least_busy: spread over the resources instead of piling onto the first free one
report, pool, _ = experiment("least_busy", [{"i": i} for i in range(6)],
resources=2, concurrency=2)
print("least_busy:", report.stats["pipelines"]["by_state"],
"leases per resource:", {s["id"]: s["leases"] for s in pool.snapshot()})
# quota_aware: prefer the resource with the most quota left (declared in options, consumed via report)
pool = make_pool("p-quota", "quota_aware", resources=2)
pool.resources()[0].options["quota"] = {"tokens": 1000}
pool.resources()[1].options["quota"] = {"tokens": 100_000}
@task("budgeted", resource="p-quota", algorithm="quota_aware")
async def budgeted(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
lease.report(ok=True, usage={"tokens": 1000})
return {"i": row["i"], "resource": lease.resource.id}
with Runner(store="runs/step06_quota.db", pools=[pool], concurrency=1) as runner:
runner.run(pipeline("t-quota", budgeted).map([{"i": i} for i in range(3)]))
picked = [row["artifacts"][-1]["payload"]["resource"] for row in runner.store.export_rows()]
print("quota_aware:", picked, "(roomiest first, then the top-up, then the exhausted one again)")
# sticky: a pipeline keeps the resource it already used (prompt caches, warm connections)
pool = make_pool("p-sticky", "sticky", resources=2)
@task("two_calls", resource="p-sticky", algorithm="sticky")
async def two_calls(row: dict, ctx) -> dict:
async with ctx.acquire() as first:
first_id = first.resource.id
async with ctx.acquire() as second:
return {"first": first_id, "second": second.resource.id, "same": first_id == second.resource.id}
with Runner(store="runs/step06_sticky.db", pools=[pool], concurrency=1) as runner:
runner.run(pipeline("t-sticky", two_calls).map([{"i": 0}]))
payload = next(iter(runner.store.export_rows()))["artifacts"][-1]["payload"]
print("sticky :", payload)
immediate : {'failed': 3, 'succeeded': 1} -> errors: ['ResourceUnavailable']
wait : {'succeeded': 4} (4 x 20ms of work on a single slot)
least_busy: {'succeeded': 6} leases per resource: {'p-least_busy-0': 3, 'p-least_busy-1': 3}
quota_aware: ['p-quota-1', 'p-quota-0', 'p-quota-1'] (roomiest first, then the top-up, then the exhausted one again)
sticky : {'first': 'p-sticky-0', 'same': True, 'second': 'p-sticky-0'}
| Algorithm | Behaviour when nothing is free | Use it when |
|---|---|---|
wait (default) |
queues until a slot frees or timeout= expires |
steady-state work, you want throughput over latency |
backoff |
waits with exponential backoff + jitter (base, factor, cap, max_wait) |
a saturated provider, so releases do not cause a thundering herd |
least_busy |
picks the lowest load ratio, falls back to waiting | several endpoints of unequal capacity |
failover |
tries a list of pools in order, then falls back | primary/backup providers, different keys per tier |
sticky |
prefers the resource this attempt already used | prompt/prefix caches, warm connections, sticky sessions |
quota_aware |
ranks by remaining ratio, then absolute headroom | budget-limited endpoints, options={"quota": {"tokens": N}} |
immediate |
raises ResourceUnavailable right away |
you would rather shed load than queue |
Two things the output makes visible:
immediatefailed 3 of 4 pipelines.ResourceUnavailableclassifies asunknown, and the default retry policy does not retryunknown. SetRetrying(max_attempts=3, retry_unknown=True)if you want capacity retries.quota_awarepickedp-quota-1first (both fresh, so the larger absolute headroom won the tie-break), thenp-quota-0(untouched, so the better ratio), thenp-quota-1again once the 1 000-token resource was spent. Quota is a preference, not a hard stop — for a hard limit, track the budget in your task and raise.
Acquire-time backoff (backoff) and retry-time backoff (Retrying) are different knobs: the first
decides how long to wait for a slot, the second how long to wait after a failure.
Step 7 — the lease contract¶
async with ctx.acquire(...) returns the resource on every exit path. This step demonstrates each of them.
# tutorial/step_07_lease_safety.py
"""Step 7 - the lease contract: every exit path returns the resource, or the framework reclaims it and says so."""
import asyncio
from pyattacker import Pool, Resource, Retrying, Runner, pipeline, task
def make_pool() -> Pool:
return Pool("apis", [Resource.create("llm", id="api-1", capacity=1)], algorithm="wait")
def event_kinds(store) -> list[str]:
return [e.kind for e in store.events(limit=100) if e.kind.startswith(("resource.", "lease."))]
# 1) an exception inside the block
@task("raises_while_holding", resource="apis", retry=Retrying(max_attempts=1))
async def raises_while_holding(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
lease.report(ok=False, error="boom")
raise RuntimeError("the request blew up") # __aexit__ still runs and still returns the lease
pool = make_pool()
with Runner(store="runs/step07a.db", pools=[pool], concurrency=1) as runner:
report = runner.run(pipeline("raises", raises_while_holding).map([{"i": 0}]))
print("1) exception:", report.stats["pipelines"]["by_state"],
"| events:", event_kinds(runner.store))
print(" pool afterwards: active =", pool.stats().active, "ready =", pool.stats().ready)
# 2) the escape hatch, with the release forgotten
@task("forgets_to_release", resource="apis")
async def forgets_to_release(row: dict, ctx) -> dict:
lease = await ctx.acquire_lease() # tracked by ctx, but not returned here
return {"held": lease.resource.id}
pool = make_pool()
with Runner(store="runs/step07b.db", pools=[pool], concurrency=1) as runner:
report = runner.run(pipeline("leaky", forgets_to_release).map([{"i": 0}]))
print("\n2) forgotten release:", report.stats["pipelines"]["by_state"],
"| leases_leaked =", report.leases_leaked, "| events:", event_kinds(runner.store))
print(" pool afterwards: active =", pool.stats().active,
"(the task still succeeded - a leak is reported, not hidden)")
# 3) the same leak, with strict_leases on
pool = make_pool()
with Runner(store="runs/step07c.db", pools=[pool], concurrency=1, strict_leases=True) as runner:
report = runner.run(pipeline("leaky", forgets_to_release).map([{"i": 0}]))
print("\n3) strict_leases:", report.stats["pipelines"]["by_state"],
"| error:", sorted({e["error_type"] for e in runner.store.errors()}),
"| leases_leaked =", report.leases_leaked)
# 4) cancellation by timeout_s
@task("too_slow", resource="apis", timeout_s=0.01)
async def too_slow(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
lease.report(ok=True)
await asyncio.sleep(5) # cancelled by timeout_s; the lease is returned on the way out
return {"never": "reached"}
pool = make_pool()
with Runner(store="runs/step07d.db", pools=[pool], concurrency=1) as runner:
report = runner.run(pipeline("slow", too_slow).map([{"i": 0}]))
attempt = runner.store.attempts()[0]
print("\n4) timeout_s:", report.stats["pipelines"]["by_state"],
"| error_class =", attempt.error_class, "| events:", event_kinds(runner.store))
print(" pool afterwards: active =", pool.stats().active,
"(cancellation cannot strand a resource)")
1) exception: {'failed': 1} | events: ['resource.leased', 'resource.released']
pool afterwards: active = 0 ready = 1
2) forgotten release: {'succeeded': 1} | leases_leaked = 1 | events: ['resource.leased', 'lease.leaked', 'resource.leaked']
pool afterwards: active = 0 (the task still succeeded - a leak is reported, not hidden)
3) strict_leases: {'failed': 1} | error: ['LeaseLeakError'] | leases_leaked = 1
4) timeout_s: {'failed': 1} | error_class = timeout | events: ['resource.leased', 'resource.released']
pool afterwards: active = 0 (cancellation cannot strand a resource)
| Scenario | Guarantee |
|---|---|
async with ctx.acquire(...) exits normally |
returned synchronously on exit |
| an exception is raised inside the block | returned anyway; __aexit__ runs and does not swallow the exception |
| acquire → use → release inside a loop | returned on every iteration, so concurrency is genuinely yielded |
timeout_s expires, the run is cancelled, Ctrl-C |
returned in finally, synchronously |
await ctx.acquire_lease() and you forget the release |
force-reclaimed when the task ends, lease.leaked + resource.leaked recorded |
| still holding a lease after the task returns | impossible — reclamation runs before the task row is written |
What to do with that:
- Use
async with ctx.acquire(...) as lease:and hold the lease only around the request. Long holds are legal, and they are what starves a pool. - A forgotten release is reported, not hidden: check
report.leases_leaked, watch for thelease.leakedevent, and setstrict_leases=Truein CI so it fails the task instead. - Holding a resource from a pool while asking the same pool for a second one is a deadlock shape. The pool
emits
acquire.suspected_deadlockafterdeadlock_warn_s(default 5 s) rather than hanging silently. Acquire both up front, or use two pools.
Step 8 — failures: classify, retry, record¶
Failures are ordinary Python exceptions. The framework classifies them, asks your policy what to do, and records every attempt and every decision.
# tutorial/step_08_retry.py
"""Step 8 - failure handling: classify once, retry by policy, keep the decision in the record."""
from pyattacker import (
FatalError,
Pool,
Resource,
RetryableError,
Retrying,
Runner,
error_class_of,
pipeline,
task,
)
class HttpError(Exception):
def __init__(self, status: int) -> None:
super().__init__(f"HTTP {status}")
self.status = status
print("classification is one pure function over any exception:")
for exc in (HttpError(429), HttpError(503), HttpError(404), TimeoutError(),
ConnectionError("reset"), ValueError("bad json"), Exception("?")):
print(f" {type(exc).__name__:<16}{str(exc):<12} -> {error_class_of(exc)}")
CALLS = {"n": 0}
@task("ask", resource="apis", retry=Retrying(max_attempts=4, base=0.01, cap=0.05))
async def ask(row: dict, ctx) -> dict:
CALLS["n"] += 1
async with ctx.acquire() as lease:
if ctx.attempt <= 2:
lease.report(ok=False, error="429")
# A retryable error carries its own class, and retry_after is a server-suggested delay.
raise RetryableError("rate limited", error_class="rate_limit", retry_after=0.01)
lease.report(ok=True)
return {"i": row["i"], "attempts": ctx.attempt}
@task("always_fatal", resource="apis", retry=Retrying(max_attempts=5))
def always_fatal(row: dict) -> dict:
raise FatalError("the request itself is invalid; another attempt cannot help")
fatal_specs = list(pipeline("fatal", always_fatal).map([{"i": 9}]))
with Runner(store="runs/step08.db", pools=[Pool("apis", [Resource.create("llm", id="api-1", capacity=4)])],
concurrency=2) as runner:
report = runner.run(pipeline("retrying", ask).map([{"i": 1}]))
print("\nretried pipeline:", report.stats["pipelines"]["by_state"],
"| requests made:", CALLS["n"])
for attempt in runner.store.attempts():
print(f" attempt {attempt.attempt_no} {attempt.outcome:<9} class={attempt.error_class or '-':<11} "
f"delay_s={attempt.retry_delay_s} why={attempt.decision.get('reason')}")
fatal = runner.run(iter(fatal_specs))
attempts = runner.store.attempts(pipeline_id=fatal_specs[0].pipeline_id)
print("fatal pipeline: ", fatal.stats["pipelines"]["by_state"],
f"| attempts used: {len(attempts)} of max_attempts=5 ->", attempts[0].error_class)
classification is one pure function over any exception:
HttpError HTTP 429 -> rate_limit
HttpError HTTP 503 -> upstream
HttpError HTTP 404 -> fatal
TimeoutError -> timeout
ConnectionError reset -> connection
ValueError bad json -> invalid
Exception ? -> unknown
retried pipeline: {'succeeded': 1} | requests made: 3
attempt 1 failed class=rate_limit delay_s=0.01 why=retryable
attempt 2 failed class=rate_limit delay_s=0.01 why=retryable
attempt 3 succeeded class=- delay_s=None why=ok
fatal pipeline: {'failed': 1} | attempts used: 1 of max_attempts=5 -> fatal
Retried by default: retryable, rate_limit, timeout, connection, upstream. Not retried by default:
invalid, fatal, cancelled, unknown. Classification reads TimeoutError, ConnectionError, an
error_class attribute, and HTTP status codes on status / status_code / http_status / code (falling
back to exc.response.status_code) — which covers the usual provider SDKs without importing them. The full
table is in the reference.
The Retrying fields you will reach for most:
| Field | Default | Meaning |
|---|---|---|
max_attempts |
1 |
total attempts — the default is no retries |
on |
() |
extra exception types to treat as retryable |
retry_unknown |
False |
also retry unknown, e.g. ResourceUnavailable |
base, factor, cap |
0.5, 2.0, 30.0 |
exponential backoff bounds |
max_total_s |
None |
give up once attempts plus delays exceed this budget |
Ways to steer it:
raise RetryableError("rate limited", error_class="rate_limit", retry_after=0.01)— carry the class and honour a server-suggested delay.retry_afteris also read from aRetry-Afterheader when the exception exposes one.raise FatalError(...)— never retried, whatevermax_attemptssays.with_retry(ask, max_attempts=5)— reuse a task under a different policy.
A retry backoff does not hold a worker: the pipeline is parked and the worker takes other work, so
concurrency stays honest. A run ending during a backoff records the parked pipeline as interrupted with its
checkpoint intact, so resume picks it up.
Step 9 — resume: what reruns, and what does not¶
A run is resumable because the store already contains everything the remaining work needs.
# tutorial/step_09_resume.py
"""Step 9 - resume: a failed pipeline continues at the first task that produced no artifact."""
from pyattacker import RetryableError, Retrying, Runner, pipeline, task
STATE = {"judge_works": False}
SENT = {"ask": 0, "judge": 0}
@task("prepare")
def prepare(seed: dict) -> dict:
return {"qid": seed["qid"], "prompt": f"explain {seed['qid']}"}
@task("ask", retry=Retrying(max_attempts=2, base=0.005, cap=0.02))
def ask(row: dict) -> dict:
SENT["ask"] += 1 # <- in real life this is the request you do not want to pay for twice
return {**row, "answer": "42"}
@task("judge", retry=Retrying(max_attempts=2, base=0.005, cap=0.02))
def judge(row: dict) -> dict:
SENT["judge"] += 1
if not STATE["judge_works"]:
raise RetryableError("the judge model is down", error_class="upstream")
return {**row, "score": 1}
template = pipeline("eval", prepare | ask | judge)
rows = [{"qid": f"q{i}"} for i in range(3)]
specs = list(template.map(rows))
with Runner(store="runs/step09.db", concurrency=2) as runner:
first = runner.run(iter(specs))
print("round 1:", first.stats["pipelines"]["by_state"], "| requests sent:", SENT,
"| attempts recorded:", len(runner.store.attempts()))
print(" failed at:", sorted({e["failed_task"] for e in runner.store.errors()}))
STATE["judge_works"] = True
second = runner.run(iter(template.map(rows)), resume=True)
print("round 2:", second.stats["pipelines"]["by_state"], "| requests sent:", SENT,
"| skipped:", second.skipped)
print(" -> ask was not re-sent; only the failed task ran again")
row = next(iter(runner.store.export_rows()))
print(f"\n checkpoint: {row['n_tasks_done']}/{row['n_tasks_total']} tasks done, state={row['state']}")
for attempt in runner.store.attempts(pipeline_id=specs[0].pipeline_id):
print(f" {attempt.task_name:<8} attempt {attempt.attempt_no} {attempt.outcome:<9} "
f"run={attempt.run_id[4:]}")
seed_artifact = runner.store.get_artifact(specs[0].pipeline_id, -1)
print(" the seed is a stored artifact too: seq=-1 ->", seed_artifact.type_name,
seed_artifact.digest[:10])
round 1: {'failed': 3} | requests sent: {'ask': 3, 'judge': 6} | attempts recorded: 12
failed at: ['judge']
round 2: {'succeeded': 3} | requests sent: {'ask': 3, 'judge': 9} | skipped: 0
-> ask was not re-sent; only the failed task ran again
checkpoint: 3/3 tasks done, state=succeeded
prepare attempt 1 succeeded run=20260915-213904-b464d3
ask attempt 1 succeeded run=20260915-213904-b464d3
judge attempt 1 failed run=20260915-213904-b464d3
judge attempt 2 failed run=20260915-213904-b464d3
judge attempt 1 succeeded run=20260915-213904-548d63
the seed is a stored artifact too: seq=-1 -> dict d6125621c2
The rules, in the order they are applied:
- The pipeline already
succeeded→ skipped entirely. Passretry_succeeded=True/--retry-succeededto re-run it anyway (it then starts from the seed, since a finished pipeline has no checkpoint left to continue). To discard the checkpoint of a pipeline that has not succeeded, usefresh_restart=True/--fresh-restart— the same switch resets a spent backward-traversal budget. Append-only history (attempts, events, handoffs) survives; a backward pipeline additionally keeps its visit occurrences and counters, while a forward pipeline reuses its task/artifact addresses by design. - The pipeline is
failedorinterruptedwithn_tasks_done > 0→ load the artifact atn_tasks_done - 1and continue at the nextseq. This is whyasksent nothing in round 2: the judge was the first task with no artifact, so only the judge ran. - The pipeline is
failedorinterruptedand its cursor already reached the end (n_tasks_done == n_tasks_total) → every task is checkpointed, so the pipeline is repaired tosucceededwithout re-running anything: the last artifact is verified (present, payload kept, decodable), marked final, andpipeline.terminal_repairedcarries the state, error and run the row was carrying. This is the shape a store failure or a kill during the final write leaves behind. If the artifact cannot be verified, rules 5 and 6 apply instead; if the repair itself fails — marking the artifact final or settling the row — the row is left untouched (original failure included) andpipeline.terminal_repair_failedrecords the attempt, so the next run still reports the original cause. - The cursor is past the end (
n_tasks_done > n_tasks_total) → not a state the Runner can create: the row is recorded asCorruptCheckpointand reported aspipeline.corrupt_cursor, never promoted to success, and the stored cursor is left in place as the evidence. - The artifact row is gone, or its payload was not kept (
journal=summary,nullbackend) → the pipeline restarts fromseq=0and recordspipeline.checkpoint_missing(this also covers rule 3 when the terminal artifact's payload is gone). - The artifact is there but cannot be decoded (a removed codec, a changed dataclass, a corrupted payload) →
the same restart, recorded as
pipeline.checkpoint_unusableso the two causes stay distinguishable (this also covers rule 3 when the terminal artifact is present but undecodable). - Otherwise the pipeline is new, and the seed artifact is stored before the first task runs.
Three things to note:
skipped=0in round 2 is correct: nothing had succeeded, three pipelines were failed and got resumed.skippedcounts rule 1 only, not "work that was not repeated".- At resume time the dataset stream only identifies pipelines — the resumed task's input comes from the store. A run interrupted mid-pipeline does not need the dataset file to still exist, only the same seeds to be enumerated.
- Every attempt keeps its
run_id, so the record shows which run did which work.
Events worth alerting on: pipeline.resumed, pipeline.skipped, pipeline.restarted,
pipeline.checkpoint_missing, pipeline.checkpoint_unusable, pipeline.deferred_interrupted,
pipeline.terminal_repaired, pipeline.terminal_repair_failed, pipeline.terminal_cleanup_failed,
pipeline.corrupt_cursor.
Two run-level events deserve the same treatment. runner.internal_error is a framework-level surprise
recorded against one pipeline while the run carries on. runner.worker_crashed is heavier: a worker died
outside its own handlers (a BaseException that is not a cancellation, raised by a store hook for example),
so the run stops, the pipeline that worker was holding is recorded failed — or interrupted if the run was
already stopping — and run() raises WorkerCrashed with the original exception as its cause. If this event
shows up, the run did not finish on its own terms: read the pipeline row, fix the cause, and rerun with
--resume.
Step 10 — reading the record¶
Everything the framework knows lives in a handful of tables. Five are keyed by pipeline_id and are the five
exportable row kinds (--rows); runs and resources have Python readers but no export path.
# tutorial/step_10_records.py
"""Step 10 - reading the record: five tables, five row kinds, three formats."""
from contextlib import closing
from pyattacker import ROW_KINDS, Runner, export_store, iter_rows, open_store, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"i": seed["i"], "value": seed["i"] * 2}
@task("check")
def check(row: dict) -> dict:
if row["i"] % 2: # deliberately fail the odd rows so the error surface has something to show
raise ValueError("odd rows are not supported")
return {"i": row["i"], "ok": True}
STORE = "runs/step10.db"
with Runner(store=STORE, concurrency=2, label="step-10") as runner:
report = runner.run(pipeline("checked", prepare | check).map([{"i": i} for i in range(4)]))
print(report.summary())
print("\nto_dict() gives you the machine-readable version of the same facts:")
print(" ", {k: v for k, v in report.to_dict().items() if k != "pipelines"})
print("\nlive counters (safe to call while a run is in flight):")
live = runner.stats()
print(" ", {k: live[k] for k in ("in_flight_pipelines", "delayed_pipelines", "buffered")})
print(" counters:", dict(live["counters"]))
print("\nerrors():")
for item in runner.store.errors():
print(f" {item['name']}/{item['failed_task']}: {item['error_type']}: {item['error_message']}")
row = next(item for item in runner.store.export_rows() if item["state"] == "failed")
print(f"\none failed pipeline {row['pipeline_id'][:12]}:")
for item in row["tasks"]:
print(f" seq={item['seq']} {item['name']:<8} {item['state']:<9} "
f"attempts={item['attempts_used']} error={item['error_class']}")
for attempt in runner.store.attempts(pipeline_id=row["pipeline_id"]):
print(f" attempt {attempt.attempt_no} of {attempt.task_name}: outcome={attempt.outcome} "
f"decision={attempt.decision.get('reason')} leases={len(attempt.leases)} "
f"duration_ms={attempt.duration_ms}")
print(" events:", [e.kind for e in runner.store.events(pipeline_id=row["pipeline_id"])][:8])
print("\nrow kinds:")
for kind in ROW_KINDS:
print(f" {kind:<10} {sum(1 for _ in iter_rows(runner.store, kind=kind))} rows")
count = export_store(runner.store, "runs/step10_attempts.csv", kind="attempts", fmt="csv")
print(f"\nexported {count} attempt rows as CSV")
# The store is a plain SQLite file: reopen it from anywhere, any process.
with closing(open_store(STORE)) as reopened:
counts = reopened.stats()
print("reopened:", counts["pipelines"]["total"], "pipelines,",
counts["attempts_total"], "attempts,", counts["events_total"], "events")
run run-... status=completed wall=0.00s
pipelines: total=4 failed=2 succeeded=2
attempts: total=8
tasks: check=4 prepare=4
errors (2):
- checked/check: ValueError: odd rows are not supported
to_dict() gives you the machine-readable version of the same facts:
{'run_id': 'run-...', 'status': 'completed', 'duration_ms': 3.973, 'skipped': 0, 'leases_leaked': 0,
'stop_reason': None, 'tasks': {'by_name': {'check': 4, 'prepare': 4}, ...}, 'attempts_total': 8, 'events_total': 13}
live counters (safe to call while a run is in flight):
{'in_flight_pipelines': 0, 'delayed_pipelines': 0, 'buffered': {'pending': 0, 'flushes': 5, ...}}
counters: {'pipelines_admitted': 4, 'pipelines_succeeded': 2, 'pipelines_done': 4, 'pipelines_failed': 2}
errors():
checked/check: ValueError: odd rows are not supported
one failed pipeline f41b0b0541a2:
seq=0 prepare succeeded attempts=1 error=None
seq=1 check failed attempts=1 error=invalid
attempt 1 of prepare: outcome=succeeded decision=ok leases=0 duration_ms=0.004
attempt 1 of check: outcome=failed decision=attempts_exhausted leases=0 duration_ms=0.004
events: ['task.succeeded', 'task.failed', 'pipeline.failed']
row kinds:
pipelines 4 rows
tasks 8 rows
attempts 8 rows
events 13 rows
artifacts 10 rows
exported 8 attempt rows as CSV
reopened: 4 pipelines, 8 attempts, 13 events
| Table | One row per | Contains | Reader |
|---|---|---|---|
runs |
run | label, status, heartbeat, config snapshot, version, host | store.get_run(id), store.stats() |
pipelines |
pipeline | state, checkpoint (n_tasks_done/…), key, seed/spec digests, tags, failure, resume chain |
store.pipelines(...), store.export_rows() |
tasks |
task of a pipeline | final state, attempts used, duration, artifact ids, error class | store.tasks(...), iter_rows(store, kind="tasks") |
attempts |
attempt | outcome, error class/type/traceback, retry decision, leases used, duration | store.attempts(...), kind="attempts" |
events |
event | the structured stream: every task/pipeline/resource transition | store.events(...), kind="events" |
artifacts |
artifact | payload, digest, codec, type, blob_ref |
store.artifacts(pid), kind="artifacts" |
report.summary()is for humans,report.to_dict()for dashboards,runner.stats()for a live view (safe mid-run),store.errors()for the failure list.attemptsis the table that answers "why did this take 40 seconds": each attempt's duration, its lease log, and its decision (retry,reason,delay_s,error_class).- Export shapes:
--rows pipelines|tasks|attempts|events|artifacts×--format jsonl|json|csv. CSV flattens nested values into compact JSON, so it opens cleanly in a spreadsheet. - Append-only tables (attempts, events) are written in batches; state (artifacts, checkpoints) is written
synchronously.
SIGKILLcan cost the last batch of history, never a checkpoint.--no-write-behindcommits everything immediately.
Step 11 — the declarative path and the CLI¶
Composition and resources can live in YAML; the logic stays in Python. The declarative layer does exactly three things: pick tasks, chain them, configure pools.
This is the first step that needs something beyond the standard library, so it is where the extra comes in:
uv add "pyattacker[yaml]" (or pip install "pyattacker[yaml]"). Only the YAML parser is optional — the
layer itself, and the same config written as .json or .toml, work without it. The parser is chosen from
the file suffix, so a .yaml file on a machine without the extra is a config error that names the extra
rather than an import traceback.
# tutorial/step_11_declarative.py
"""Step 11 - the declarative path: composition and resources in YAML, logic still in Python."""
import json
from pathlib import Path
from pyattacker import Runner, load_spec
CONFIG = """
run: { store: runs/step11.db, concurrency: 4, label: declarative }
pools:
apis:
kind: llm
algorithm: backoff # back off instead of failing when every slot is busy
capacity: 2 # pool-level default, overridable per resource
degrade_after: 3 # 3 consecutive failures -> circuit-break for cooldown_s
cooldown_s: 10
resources:
- { id: api-1, options: { model: gpt-4o, base_url: "https://api-a.example/v1",
api_key: "${OPENAI_KEY:-sk-demo}" } }
- { id: api-2, options: { model: gpt-4o } }
pipeline:
name: qa_eval
tags: { bench: tutorial }
tasks:
- use: pyattacker.tasks:echo # replace with "your.pkg.tasks:fetch"
- use: pyattacker.tasks:simulate_llm # replace with "your.pkg.tasks:ask_model"
resource: apis
timeout_s: 30
kwargs: { latency_ms: 5, fail_rate: 0.0, tokens: 64, model: gpt-4o }
retry: { max_attempts: 3, base: 0.01, cap: 0.05, "on": [RetryableError, TimeoutError] }
source: { kind: range, n: 8 }
"""
Path("qa.yaml").write_text(CONFIG, encoding="utf-8")
spec = load_spec("qa.yaml") # <- builds the pools + the pipeline template, imports nothing of yours yet
print(json.dumps(spec.describe(), indent=2))
print("unresolved ${ENV} references:", spec.unresolved_env or "none")
with Runner(pools=spec.pools, **spec.run) as runner:
report = runner.run(spec.pipelines())
print(report.summary())
{
"config": "qa.yaml",
"pipeline": {"name": "qa_eval", "tasks": ["mock.echo", "mock.llm"],
"tags": {"bench": "tutorial"}, "spec_digest": "ad271b5b5b00fcb2..."},
"pools": {"apis": {"kind": "llm", "resources": 2, "capacity": 4, "algorithm": "backoff"}},
"run": {"store": "runs/step11.db", "concurrency": 4, "label": "declarative"},
"source": {"kind": "range", "n": 8},
"unresolved_env": []
}
unresolved ${ENV} references: none
run run-... status=completed wall=0.01s
pipelines: total=8 succeeded=8
tasks: mock.echo=8 mock.llm=8
The same config from the command line:
uv run pyattacker validate -c qa.yaml # parse + summarise, run nothing (exit 2 on a config error)
uv run pyattacker run -c qa.yaml --progress # run; --limit N for a slice
uv run pyattacker resume -c qa.yaml # same as run --resume
uv run pyattacker report runs/step11.db --errors 20
uv run pyattacker watch runs/step11.db # live view from a second process
uv run pyattacker export runs/step11.db out.jsonl --rows attempts --format csv
uv run pyattacker serve runs/step11.db # read-only HTTP debug endpoint (Step 14)
uv run pyattacker plugins # entry points, and which ones failed to load
Config sections:
| Section | Keys |
|---|---|
run |
store, concurrency, journal (full keeps payloads, summary keeps only digests), label, strict_leases, stop_after_failures, stop_after_s, retry_succeeded, fresh_restart, heartbeat_s, grace_s, stale_after_s, notes |
pools.<name> |
kind, algorithm, capacity (default for its resources), degrade_after, dead_after, cooldown_s, deadlock_warn_s, resources: [{id, kind, capacity, options, tags}] |
pipeline |
name, tags, include_code, and tasks: [{use, name, resource, algorithm, timeout_s, retry, args, kwargs}] |
source |
kind: range (n) or kind: jsonl (path, limit), plus repeats, key_field |
Details that save time:
use:is resolved by shape: a name containing a colon ismodule:attributeand is imported directly; a bare name is looked up in the built-ins first and then in installed plugins, so a plugin cannot shadowecho.pyattacker.tasks:simulate_llmandyour_pkg.tasks:ask_modelare both valid with no plugin. A factory (a callable returning aTaskSpec) is called withargs/kwargs.${VAR}and${VAR:-default}are expanded in every string value (keys are left alone). Unresolved names are reported byvalidateand bydescribe()["unresolved_env"];--strict-envmakes them an error.- YAML 1.1 pitfall: a bare
on:key parses as booleantrue. Write"on": [RetryableError, TimeoutError]— the loader detects the mistake and says so. - Exit codes:
0all succeeded,1some failed,2config error,130interrupted.
Every flag of every command is in docs/cli.md.
Step 12 — sharding and merging¶
Scaling out means several processes with their own stores, joined afterwards. A pipeline's shard comes from its content-addressed key, so the same dataset always splits the same way.
# tutorial/step_12_shards.py
"""Step 12 - scale out: deterministic shards with their own stores, merged into one answer."""
from pyattacker import (
Runner,
merge_reports,
pipeline,
shard_index,
shard_specs,
shard_store_path,
task,
)
@task("ask")
def ask(seed: dict) -> dict:
return {"i": seed["i"], "answer": seed["i"] * 3}
template = pipeline("eval", ask, tags={"example": "step-12"})
specs = list(template.map([{"i": i} for i in range(12)]))
SHARDS = 3
BASE = "runs/step12.db"
paths = []
for index in range(SHARDS):
mine = list(shard_specs(specs, index, SHARDS)) # blake2b(pipeline_key) % SHARDS == index
path = shard_store_path(BASE, index, SHARDS) # runs/step12.shard0of3.db ...
with Runner(store=path, concurrency=2, label=f"shard-{index}") as runner:
report = runner.run(iter(mine))
paths.append(path)
print(f"shard {index}: {len(mine):>2} pipelines -> {report.stats['pipelines']['by_state']} -> {path}")
merged = merge_reports(paths)
print("\n" + merged.summary())
print("pipeline rows:", len(merged.rows), "| duplicates dropped:", merged.duplicates,
"| sources:", len(merged.sources))
# Merging is idempotent: a duplicated store is de-duplicated by pipeline_id, best state wins.
again = merge_reports([*paths, paths[0]])
print("with one store counted twice:", len(again.rows), "rows,", again.duplicates, "duplicates dropped")
print(" attempts:", merged.stats()["attempts_total"], "->", again.stats()["attempts_total"],
"| source_events:", merged.stats()["source_events_total"], "->",
again.stats()["source_events_total"], "(raw, so it follows the sources)")
merged.export("runs/step12-all.jsonl")
with open("runs/step12-all.jsonl", encoding="utf-8") as handle:
print("merged export rows:", sum(1 for _ in handle))
# Sharding is content-addressed, not round-robin: re-running the same seeds replays the same split.
print("shard of the first pipeline:", shard_index(specs[0].pipeline_id, SHARDS), "of", SHARDS)
shard 0: 6 pipelines -> {'succeeded': 6} -> runs/step12.shard0of3.db
shard 1: 5 pipelines -> {'succeeded': 5} -> runs/step12.shard1of3.db
shard 2: 1 pipelines -> {'succeeded': 1} -> runs/step12.shard2of3.db
merged 12 pipelines from 3 store(s)
succeeded=12 attempts=12 source_events=27
tasks: ask=12
pipeline rows: 12 | duplicates dropped: 0 | sources: 3
with one store counted twice: 12 rows, 6 duplicates dropped
attempts: 12 -> 12 | source_events: 27 -> 40 (raw, so it follows the sources)
merged export rows: 12
shard of the first pipeline: 1 of 3
# N children, one per shard, then a merged report (results land in runs/qa.shard0of4.db …)
uv run pyattacker run -c examples/qa_eval.yaml --shards 4 --jobs 4 --store runs/qa.db
# or drive each shard yourself — cluster, job scheduler, four terminals
uv run pyattacker run -c examples/qa_eval.yaml --shard 0/4 --store runs/qa.shard0.db
uv run pyattacker resume -c examples/qa_eval.yaml --shard 0/4 --store runs/qa.shard0.db
# one coherent answer out of N files
uv run pyattacker report runs/qa.shard*of4.db
uv run pyattacker export runs/qa.shard*of4.db runs/all.jsonl
shard_index(key, N)hashes the key withblake2brather than Python'shash()(which is salted per process), so the split is stable across runs and machines.--shard 2/4 --resumetherefore puts every pipeline back where it was. Sizes are only roughly equal — 6/5/1 for twelve pipelines is not a bug.- Merging de-duplicates by
pipeline_id, keeps the best state (succeeded > failed > interrupted, then the latest finish), and recomputes the workload counters —attempts_total,handoffs_total— from the surviving rows. A partially-overlapping re-run, or a store counted twice, still produces one answer. The event log is the deliberate exception: an event is not part of a pipeline row, sosource_events_totalis a raw total over the sources you passed, and it is named that way rather than pretending otherwise. merge_reports([...])returns aMergedReportwith.summary(),.stats(),.errors()and.export(path, fmt=..., kind=...).
Step 13 — capstone: a small model evaluation¶
This is the shape of a real evaluation: prepare → a two-turn model call → three judges → reduce, with two model pools, retries, a provider outage, and a resume whose cost we actually measure.
# tutorial/step_13_capstone.py
"""Step 13 - capstone: a small model evaluation, with the price of its checkpoint granularity measured."""
import asyncio
from pyattacker import (
Pool,
Resource,
RetryableError,
Retrying,
Runner,
fanout,
pipeline,
task,
)
class RequestLog:
"""Counts what actually left the process - the number this whole exercise is about."""
def __init__(self) -> None:
self.sent: list[str] = []
def note(self, model: str) -> None:
self.sent.append(model)
def counts(self, since: int = 0) -> dict[str, int]:
out: dict[str, int] = {}
for model in self.sent[since:]:
out[model] = out.get(model, 0) + 1
return out
LOG = RequestLog()
DOWN: set[str] = {"judge-b"} # the provider having a bad day; cleared before the resume round
class Backend:
"""Your client. The framework never opens a socket; this class is where the network lives."""
def __init__(self, log: RequestLog, model: str) -> None:
self.log = log
self.model = model
async def chat(self, prompt: str) -> str:
self.log.note(self.model)
await asyncio.sleep(0.002) # <- the HTTP request
if self.model in DOWN:
raise RetryableError(f"{self.model} is unavailable", error_class="upstream")
return f"[{self.model}] {prompt[:40]}"
def backend_factory(resource: Resource) -> Backend:
return Backend(LOG, resource.options["model"])
@task("prepare")
def prepare(seed: dict) -> dict:
return {"qid": seed["qid"], "question": seed["question"]}
@task("ask", resource="models", timeout_s=10)
async def ask(row: dict, ctx) -> dict:
"""One task = one step of the pipeline, but it may make as many requests as it needs."""
messages = [f"Q: {row['question']}"]
async with ctx.acquire(model="eval-model") as lease:
for _ in range(2): # a two-turn conversation, inside a single checkpoint
messages.append(await lease.client.chat(" | ".join(messages)))
lease.report(ok=True, usage={"tokens": 64})
return {**row, "transcript": messages}
def judge(model: str):
@task(f"judge.{model}", resource="judges", algorithm="least_busy",
retry=Retrying(max_attempts=2, base=0.005, cap=0.02))
async def judge_with(row: dict, ctx) -> dict:
async with ctx.acquire(model=model) as lease:
verdict = await lease.client.chat(row["transcript"][-1])
lease.report(ok=True, usage={"tokens": 8})
return {**row, "judge": model, "verdict": verdict}
return judge_with
judges = fanout(judge("judge-a"), judge("judge-b"), judge("judge-c"), name="judges")
@task("reduce")
def reduce_scores(row: dict) -> dict:
"""Aggregation across pipelines stays yours to write - the framework stores it, it does not judge it."""
branches = list(row.values())
return {
"qid": branches[0]["qid"],
"verdicts": {b["judge"]: b["verdict"] for b in branches},
"n_models": len(branches),
}
def make_pools() -> list[Pool]:
return [
Pool("models", [Resource.create("llm", id="m-1", capacity=4,
options={"model": "eval-model"}, factory=backend_factory)]),
Pool("judges", [Resource.create("llm", id=f"j-{name}", capacity=1,
options={"model": name}, factory=backend_factory)
for name in ("judge-a", "judge-b", "judge-c")]),
]
template = pipeline("eval", prepare | ask | judges | reduce_scores, tags={"bench": "tutorial"})
rows = [{"qid": "q1", "question": "2 + 2 = ?"}, {"qid": "q2", "question": "3 + 4 = ?"}]
with Runner(store="runs/step13.db", pools=make_pools(), concurrency=4) as runner:
first = runner.run(template.map(rows))
print("round 1:", first.stats["pipelines"]["by_state"],
"| requests:", LOG.counts(),
"| failed at:", sorted({e["failed_task"] for e in runner.store.errors()}))
DOWN.clear()
mark = len(LOG.sent)
second = runner.run(template.map(rows), resume=True)
print("round 2:", second.stats["pipelines"]["by_state"],
"| requests:", LOG.counts(mark), "| skipped:", second.skipped)
print("total requests:", len(LOG.sent))
row = next(iter(runner.store.export_rows()))
print("\nfinal artifact:", row["artifacts"][-1]["payload"])
# The resume was honest but not free: judge-a and judge-c were re-sent even though their verdicts
# were already on disk. Putting the three judges in three *tasks* (C1 | C2 | C3) makes the
# checkpoint finer: the same failure would resume at C2, and re-send nothing that succeeded.
# examples/llm_eval/ measures both shapes side by side.
print("\nthe grouped shape re-sent verdicts that were already persisted:",
sum(LOG.counts(mark).values()) - 2, "of", len(LOG.sent) - mark, "requests in round 2")
round 1: {'failed': 2} | requests: {'eval-model': 4, 'judge-a': 4, 'judge-b': 4, 'judge-c': 4} | failed at: ['judges']
round 2: {'succeeded': 2} | requests: {'judge-a': 2, 'judge-b': 2, 'judge-c': 2} | skipped: 0
total requests: 22
final artifact: {'n_models': 3, 'qid': 'q1',
'verdicts': {'judge-a': '...', 'judge-b': '...', 'judge-c': '...'}}
the grouped shape re-sent verdicts that were already persisted: 4 of 6 requests in round 2
What each piece does:
askmakes two requests inside one task. A multi-turn conversation, a tool loop, or a "retry the parse until it validates" loop all belong inside one task. One step, one artifact.fanout(...)keeps the branch inside a task. Three judges run concurrently on the same input and return{task_name: value}. Children share the parent's context, so their leases and events land in one record. The Runner only sees the group spec, soresource,algorithmandtimeout_sare lifted from the children only when all of them agree — which is why the three judges declare the same pool and algorithm.- Pools per role.
modelshas one resource with capacity 4;judgeshas one resource per model with capacity 1, so the three branches never queue behind each other. - The outage becomes data.
judge-b'sRetryableError(error_class="upstream")is classified, retried by the group's policy, and recorded as a failure with the failing task named. - The resume shows its cost. Round 2 re-ran the whole judges group, so
judge-aandjudge-cwere sent again even though their verdicts were on disk — four of six requests were repeats.
That last point is the choice you have to make when a step branches:
If a request is expensive or slow, give it its own task. If a step fans out over cheap calls, one task and one checkpoint is the better trade.
Three judges as three tasks (C1 | C2 | C3) makes the checkpoint finer: the same failure resumes at C2 and
re-sends nothing that succeeded, at the cost of two extra pipeline steps.
examples/llm_eval/ implements both shapes over the same tasks and measures
them — with one judge endpoint down, the grouped shape re-sent 2 already-successful requests and the split
shape re-sent 0.
Step 14 — your own types, blobs, plugins¶
Three extension points cover almost everything: codecs for your types, artifact backends for where payloads live, and entry-point plugins for making your code addressable from a config file.
# tutorial/step_14_extending.py
"""Step 14 - your own types and your own blobs: a codec, a size comparison, and an artifact backend."""
import json
import struct
from pathlib import Path
from pyattacker import CodecRegistry, FileBackend, Runner, list_plugins, pipeline, task
class Embedding:
"""A type the built-in JSON codec would store as a fat list of floats."""
def __init__(self, values: list[float]) -> None:
self.values = values
def __len__(self) -> int:
return len(self.values)
def __repr__(self) -> str:
return f"Embedding({len(self.values)} floats)"
class EmbeddingCodec:
"""Four methods: a name, a claim on a type, and an encode/decode pair."""
name = "f32"
def can_encode(self, obj: object) -> bool:
return isinstance(obj, Embedding)
def dumps(self, obj: Embedding) -> bytes:
return struct.pack(f"<{len(obj.values)}f", *obj.values)
def loads(self, data: bytes) -> Embedding:
return Embedding(list(struct.unpack(f"<{len(data) // 4}f", data)))
# A private registry: the same object must be given to the pipeline (for seeds) and to the Runner.
registry = CodecRegistry()
registry.register(EmbeddingCodec(), for_types=(Embedding,))
@task("embed")
def embed(seed: dict) -> Embedding:
return Embedding([float(i) for i in range(seed["dim"])])
@task("norm")
def norm(vec: Embedding) -> dict:
return {"dim": len(vec), "norm": round(sum(v * v for v in vec.values) ** 0.5, 3)}
template = pipeline("embedding", embed | norm, registry=registry)
with Runner(store="runs/step14.db", pools=[], concurrency=2, registry=registry,
# Spill payloads of any size to content-addressed files instead of the database.
artifact_backend=FileBackend(root="runs/blobs", min_bytes=0)) as runner:
report = runner.run(template.map([{"dim": 384}]))
print(report.summary())
row = next(iter(runner.store.export_rows()))
for item in row["artifacts"]:
shown = str(item["payload"])[:36]
print(f" seq={item['seq']:>2} {item['task']:<10} type={item['type']:<10} codec={item['codec']:<5} "
f"payload={shown + '...' if len(shown) == 36 else shown}")
pipeline_id = row["pipeline_id"]
vector = runner.store.get_artifact(pipeline_id, 0)
print("built-in json would need", len(json.dumps([float(i) for i in range(384)])), "bytes;",
"the f32 codec used", vector.size, "bytes")
print("the store kept only a reference:", f"blobs/{vector.digest[:2]}/{vector.digest[2:]}",
"- the payload is not in the database")
print("restored from the blob:", type(registry.load(vector.encoded())).__name__,
registry.load(vector.encoded()))
files = [p for p in sorted(Path("runs/blobs").rglob("*")) if p.is_file()]
print("blobs on disk:", len(files), "files,", sorted(p.stat().st_size for p in files), "bytes")
print("installed plugins:", len(list_plugins()),
"(entry points; see examples/plugin_package for a complete one)")
run run-... status=completed wall=0.01s
pipelines: total=1 succeeded=1
...
seq=-1 __seed__ type=dict codec=json payload={'dim': 384}
seq= 0 embed type=Embedding codec=f32 payload=AAAAAAAAgD8AAABAAABAQAAAgEAAAKBAAADA...
seq= 1 norm type=dict codec=json payload={'dim': 384, 'norm': 4335.978}
built-in json would need 2578 bytes; the f32 codec used 1536 bytes
the store kept only a reference: blobs/15/0b9b3a521bb02c0f88eab2716c9d61 - the payload is not in the database
restored from the blob: Embedding Embedding(384 floats)
blobs on disk: 3 files, [11, 27, 1536] bytes
- Codecs need four members:
name,can_encode(obj),dumps(obj) -> bytes,loads(bytes) -> obj. Register withregistry.register(codec, for_types=(MyType,))and pass the same registry topipeline(..., registry=...)for seeds and toRunner(registry=...)for artifacts. A codec that claims a payload beats the built-in JSON catch-all. - Artifact backends decide where payload bytes live:
None/"inline"keeps them in the database,"null"drops them and keeps digests,"file:///data/blobs"(or{"kind": "file", "root": ..., "min_bytes": 262144}) spills to content-addressed files that are hydrated back on read.min_bytes=0above spills everything, which is why the artifact row shows a reference instead of bytes. - Plugins are
importlib.metadataentry points in four groups:pyattacker.tasks,pyattacker.algorithms,pyattacker.codecs, andpyattacker.stores(keyed by URI scheme, sostore = "s3://bucket/runs.db"works). Built-ins resolve first, and a plugin that raises on import is recorded rather than fatal —pyattacker pluginsshows both. A complete worked package isexamples/plugin_package/.
Monitoring a run in progress. pyattacker watch runs/qa.db gives you a terminal view from a second
process, and pyattacker serve runs/qa.db an HTTP dashboard plus JSON at /stats, /metrics, /events, /pipelines,
/resources and /errors. Both open read-only connections, so they are safe beside a live run. The HTTP
endpoint has no authentication and serves your payloads — keep it on loopback.
For live experiment accuracy, run examples/live_metrics.py. Its
completion callback decodes final artifacts, deduplicates by pipeline ID, and reports the current
accuracy through runner.report_metric(); the dashboard displays the value as it changes.
* Monitoring: pyattacker serve runs/qa.db is a zero-dependency read-only HTTP view (/,
/stats, /metrics, /events, /pipelines, /resources, /errors) that opens a fresh connection per request,
so it runs happily beside a live run. It binds to loopback and has no authentication — treat it as a
debug view, not a dashboard.
Step 15 — skipping stations (handoffs)¶
Handoffs are a supported control-flow feature, enabled explicitly through control. Everything before this step works without it, and a pipeline that does
not declare it behaves exactly as it did before the feature existed. Read this step when you have a step that
decides the rest of the chain no longer needs to run.
The situation: judge can tell that an answer is already good enough, or that the metrics step is
unnecessary for this row. The old options were to run the remaining tasks anyway, to fold everything into one
task with fanout (losing per-step records), or to raise — which records the pipeline as failed, which is
a lie. A handoff says what actually happened: this row skipped stations 3–5 and continued at station 6, or
finished right here.
# tutorial/step_15_handoff.py
"""Step 15 - a task hands off: skip stations, or finish the pipeline, and nothing lies about it."""
import json
from contextlib import closing
from pyattacker import Handoff, Runner, open_store, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"q": seed["q"], "confidence": seed["confidence"]}
@task("ask")
async def ask(row: dict, ctx) -> dict:
# <- your HTTP call: the model answers, and reports how sure it is
await ctx.clock.sleep(0.001)
return {**row, "answer": f"answer-for:{row['q']}"}
@task("judge")
def judge(row: dict) -> Handoff | dict:
"""Three outcomes: finish here, skip metrics, or carry on down the chain."""
if row["confidence"] >= 0.9:
# already good enough: end the pipeline with this as its final artifact
return Handoff.end({"q": row["q"], "answer": row["answer"], "verdict": "confident"},
reason="already good enough")
if row["confidence"] >= 0.5:
# not worth the metrics call, but the report is still wanted: continue at "report"
return Handoff.to("report", {"q": row["q"], "answer": row["answer"], "verdict": "ok"},
reason="metrics not needed")
return {**row, "verdict": "needs-metrics"}
@task("metrics")
def metrics(row: dict) -> dict:
return {**row, "score": round(row["confidence"] * 10, 1)}
@task("report")
def report(row: dict) -> dict:
return {**row, "reported": True}
# The edges are declared, not derived: "report" is what judge may jump to, and END finishes the pipeline.
template = pipeline(
"qa",
prepare | ask | judge | metrics | report,
control={"edges": {"judge": ["report", "end"]}},
)
rows = [
{"q": "capital of France", "confidence": 0.95}, # judge ends the pipeline (metrics and report skipped)
{"q": "17 * 23", "confidence": 0.6}, # judge skips metrics, continues at report
{"q": "prove sqrt(2) is irrational", "confidence": 0.2}, # judge hands nothing off: the full chain runs
]
with Runner(store="runs/handoff.db", concurrency=4) as runner:
report_obj = runner.run(template.map(rows))
print(report_obj.summary())
store = runner.store
for record in store.pipelines():
ran = [t.name for t in store.tasks(record.pipeline_id)]
skipped = [t.name for t in template.tasks if t.name not in ran]
print(f"\n{record.pipeline_id[:8]} state={record.state} ran={ran} skipped={skipped}")
for hop in store.handoffs(pipeline_id=record.pipeline_id):
where = "END" if hop.to_seq is None else hop.to_task
print(f" jumped: {hop.from_task} -> {where} ({hop.reason}) entry={hop.entry_artifact_id}")
# The record is a plain store: reopen it whenever, and read the same ledger back.
with closing(open_store("runs/handoff.db")) as reopened:
print("\nhandoffs recorded:", reopened.stats()["handoffs_total"])
row = next(iter(reopened.export_rows()))
print("first exported pipeline's ledger:", json.dumps(row["handoffs"], ensure_ascii=False))
The three rows take three different paths, and the record says so without any of them having failed:
ec5fc1e5 state=succeeded ran=['prepare', 'ask', 'judge'] skipped=['metrics', 'report']
jumped: judge -> END (already good enough) entry=ec5fc1e5dff6a260512e579a276d11e0:5
cdce72ca state=succeeded ran=['prepare', 'ask', 'judge', 'report'] skipped=['metrics']
jumped: judge -> report (metrics not needed) entry=cdce72cae311e870d87d85e9dbcaa280:5
260cf635 state=succeeded ran=['prepare', 'ask', 'judge', 'metrics', 'report'] skipped=[]
What is worth knowing before you use it:
- It is a return value, not an exception. The retry policy never sees it, a task-side
except Exception:cannot swallow it, andasync with ctx.acquire(...)has already returned its leases on the way out. A cancelled or timed-out attempt never reaches the return, so nothing is half-transferred. - The edges are declared, so a mistake is loud. Returning a
Handofffrom a pipeline with nocontrolblock, or along an edge that was not declared from that task, is aFatalError— never retried, never a silent jump. Destinations must be strictly later than their source (theedgesoperation is forward-only), a name that appears twice in the chain must be given as a seq, andendfrom the last task is refused because it would do nothing. - A handoff is a checkpoint, so resume continues at the target. If the process dies after the jump, the
next
resume=Truerun starts at the destination with the recorded entry state and does not re-run the source task. Thehandoffsrow is what makes that possible: it names the destination and the entry artifact, which is either the artifact the source task received (Handoff.to(target)with no value) or a new payload stored at its own address above the chain (seq >= n_tasks). n_tasks_donebecomes a position. The skipped slots have no task rows, so on a control-enabled pipelinen_tasks_done / n_tasks_totalis not a completion percentage —store.handoffs()andstats()["handoffs_total"]are how you see what actually happened.report,watch,/pipelinesand every export show the handoff count next to it.- Do not reach for it first. A handoff is a scheduling statement about the current pipeline: "this row
should continue over there". Conditions still belong in task code, branching inside a step is still
fanout, and iterating a dataset is stillmap. A pipeline that is mostly handoffs is a sign the problem wants a graph engine, which this is not. - Supported control flow. The
HandoffAPI andcontroldeclarations are part of the public API and follow the project's versioning policy. Separately declared backward operations use visits and finite budgets; see Step 16 and reference → backward traversal. Both built-in stores can commit a handoff; a custom store that cannot is refused up front with aConfigErrorrather than writing a jump that would not survive a crash.
A whole-pipeline restart (an explicit fresh_restart=True, a pipeline that already succeeded under
retry_succeeded=True, or a lost entry payload) keeps previous handoffs, attempts and events as history, but
atomically clears current task/chain artifact state
with the durable ledger watermark before restarting at seq 0. A later failure
therefore cannot resume an old jump or recover an old END result. Subsequent target resumes preserve the
current watermark, so repeated interruptions still use the active handoff. Completion leaves one final
artifact. See the store recovery contract when implementing a backend.
Step 16 — regenerating with rewind and retry-all¶
Like forward jumps, rewind and retry-all are supported features enabled through control. It changes the traversal of
the chain: a validator can send the work back to an earlier station instead of failing the row or looping
inside one task. Read it when a step decides that an earlier step should run again with different state.
The situation: validate rejects a structured answer, and the fix is another generation with the same
prompt plus the error feedback. Folding that loop into one task would collapse generation and validation
into one record — "how many regenerations did this row need" would be invisible exactly where it is the
measurement. A rewind keeps both generations as separate visits with their own task, attempt and
artifact rows.
# tutorial/step_16_backward.py
"""Step 16 - a validator rewinds to the generator; state is what the author chose."""
from pyattacker import Handoff, Runner, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"prompt": seed["prompt"], "temperature": 0.2}
@task("generate")
def generate(state: dict, ctx) -> dict:
# <- your model call: a revisit is a genuinely new sample
return {**state, "answer": f"sample-{ctx.visit}", "valid": ctx.visit >= 1}
@task("validate")
def validate(row: dict, ctx) -> Handoff | dict:
if not row["valid"]:
# Explicit state: the author decides what the generator gets, the framework guesses nothing.
return Handoff.rewind(
"generate",
{"prompt": row["prompt"], "temperature": 0.7, "feedback": "answer did not parse"},
reason="invalid structured output",
)
return {**row, "validated": True}
@task("report")
def report(row: dict, ctx) -> dict:
# ctx.visit counts entries into *this* station, and report ran once — so it is 0 here. How many
# regenerations the row needed is the generate station's counter, printed from the store below.
return {"answer": row["answer"], "temperature": row["temperature"]}
# `rewind` is declared from validate to the strictly earlier generate; max_handoffs is required and is
# the loop budget: transfer N+1 fails before it can invalidate anything.
template = pipeline(
"regenerate",
prepare | generate | validate | report,
control={"rewind": {"validate": ["generate"]}, "max_handoffs": 3},
)
with Runner(store="runs/backward.db", max_handoffs=10) as runner: # 10 is a ceiling, not a floor
report_obj = runner.run(template.map([{"prompt": "Return JSON with one field"}]))
print(report_obj.summary())
store = runner.store
for record in store.pipelines():
print(f"\n{record.pipeline_id[:8]} state={record.state} position={record.n_tasks_done}")
for row in store.tasks(record.pipeline_id):
print(f" seq={row.seq} visit={row.visit} {row.name:9s} {row.state}")
for hop in store.handoffs(pipeline_id=record.pipeline_id):
print(f" {hop.operation}: {hop.from_task} -> {hop.to_task} (visit {hop.from_visit} -> {hop.to_visit})")
state = store.visit_state(record.pipeline_id)
print("\nbudget consumed:", state["handoffs"], " visits per station:", state["counters"])
print("effective outputs:", {s: store.get_artifact(record.pipeline_id, s).visit for s in range(4)})
# Every occurrence is still there by exact id, including the superseded first generation.
print("first generation kept:", store.get_artifact_by_id(f"{record.pipeline_id}:1").payload)
The row is generated twice, and the record shows both visits instead of hiding the retry:
succeeded position=4
seq=0 visit=0 prepare succeeded
seq=1 visit=0 generate succeeded
seq=1 visit=1 generate succeeded
seq=2 visit=0 validate handed_off
seq=2 visit=1 validate succeeded
seq=3 visit=0 report succeeded
rewind: validate -> generate (visit 0 -> 1)
budget consumed: 1 visits per station: {'0': 0, '1': 1, '2': 1, '3': 0}
effective outputs: {0: 0, 1: 1, 2: 1, 3: 0}
What is worth knowing before you use it:
- You choose the state; the framework does not roll back dictionaries.
Handoff.rewind(target, value)requires an explicit value (Noneis a real value), and the target must be a declared, strictly earlier task. Results before the target stay effective; the target and everything after it become historical and run again with new visits. Handoff.retry_all()restarts from the original bound seed, freshly decoded from the bytes captured at binding — mutatingspec.seedor a task's input afterwards does not change it. UseHandoff.rewind(0, chosen_state)when the restart should begin with different state.- Loops are bounded, so termination is not structural.
control.max_handoffsis required for a backward plan, andRunConfig.max_handoffs(default 1000,run.max_handoffsin a config) can lower it: the effective limit is the minimum.ENDnever consumes a transfer. The failing transition is refused before anything is invalidated, so the record still describes exactly what committed. - Getting out of a spent budget, and out of a
runningrow, is explicit.resume=Truekeeps the consumed budget and continues the current traversal, so a pipeline that failed because it ran out of transfers needsfresh_restart=True/--fresh-restart: a new budget lifecycle, a restart from the bound seed, and visit counters plus audit rows preserved. And a row that still saysrunning— the shape a hard kill leaves — is only claimed withresume=True; without it the run skips it instead of forking a traversal another run may still own. - A crash cannot lose the lineage. Entry, visit allocation, effective slots and the pending input commit
atomically, so a resume continues the same visit with its chosen payload rather than replaying the whole
pipeline. What the framework does not give you is exactly-once external side effects — include
ctx.visitin an idempotency key when each regeneration should be a new external operation. - Visit 0 is unchanged. A pipeline that never rewinds keeps
pipeline_id:seqids, the same random stream and the samespec_digest, so adding the declaration is what opts a pipeline into the new addresses — not upgrading the package. - Ordinary dictionaries stay ordinary.
HistoryArtifactis an optional payload base class for snapshot/restore bookkeeping; nothing in the runner reads it to decide where to go next.
The full interface — the visit/occurrence model, the budget lifecycle, the store capability and the
HistoryArtifact codec — is in reference → backward traversal,
and the optional payload history gets its own step next.
Step 17 — payloads that carry their own history¶
Payload history is an optional companion to control flow and changes nothing about scheduling. Steps 15 and 16 decide where the pipeline goes; this one is the optional companion to a rewind, for when the state you send back should carry named checkpoints of its own.
The situation: validate wants to send generate back to the state as it was before the sample that
failed — not to a dictionary the author rebuilt by hand. Building that dictionary is the common case and it
is what Step 16 does; there is nothing wrong with it. But when the payload is the application's state
machine — it has stages, earlier stages are worth keeping, and "regenerate from stage 2 while stage 3 stays
on the record" is the operation you want — HistoryArtifact is an optional payload base class that carries
detached snapshots of application state plus a versioned codec for them.
# tutorial/step_17_history.py
"""Step 17 - optional payload history: named checkpoints carried inside the payload."""
from pyattacker import CodecRegistry, Handoff, HistoryArtifact, Runner, pipeline, task
class DraftState(HistoryArtifact):
"""Application state with its own snapshots; the runner stores it and never reads it."""
@task("prepare")
def prepare(seed: dict) -> DraftState:
state = DraftState({"prompt": seed["prompt"], "temperature": 0.2})
return state.checkpoint("prepared")
@task("generate")
def generate(state: DraftState, ctx) -> DraftState:
# <- your model call: a revisit is a genuinely new sample, so the label names the visit
draft = state.with_state({**state.state, "answer": f"sample-{ctx.visit}", "valid": ctx.visit >= 1})
return draft.checkpoint(f"sample-{ctx.visit}", metadata={"temperature": draft.state["temperature"]})
@task("validate")
def validate(state: DraftState, ctx) -> Handoff | DraftState:
if not state.state["valid"]:
# Go back to the snapshot taken before this sample, then choose what the generator sees next.
base = state.restore("prepared")
return Handoff.rewind(
"generate",
base.with_state({**base.state, "temperature": 0.7}),
reason="answer did not parse",
)
return state
@task("report")
def report(state: DraftState, ctx) -> dict:
# The decoded payload still carries every snapshot, in order, plus the one that is selected.
return {
"answer": state.state["answer"],
"labels": [row["label"] for row in state.history],
"selected": state.selected,
}
registry = CodecRegistry()
registry.register_type(DraftState) # decoding restores DraftState, not a bare HistoryArtifact
# The same registry goes to the template (for seeds) and to the Runner (for checkpoints) - Step 14's rule.
template = pipeline(
"draft",
prepare | generate | validate | report,
registry=registry,
control={"rewind": {"validate": ["generate"]}, "max_handoffs": 3},
)
with Runner(store=":memory:", registry=registry) as runner:
report_obj = runner.run(template.map([{"prompt": "Return JSON with one field"}]))
print(report_obj.summary())
store = runner.store
record = next(iter(store.pipelines()))
for row in store.tasks(record.pipeline_id):
print(f" seq={row.seq} visit={row.visit} {row.name:9s} {row.state}")
validate_output = runner.registry.load(store.get_artifact(record.pipeline_id, 2).encoded())
print("\nfinal artifact:", runner.registry.load(store.get_artifact(record.pipeline_id, 3).encoded()))
print("snapshots:", [row["label"] for row in validate_output.history])
print("selected:", validate_output.selected)
pruned = validate_output.prune("sample-0") # explicit, and never the selected snapshot
print("after pruning sample-0:", [row["label"] for row in pruned.history])
try:
pruned.prune("prepared")
except ValueError as exc:
print("prune refused:", exc)
The traversal is Step 16's; what is new is that the payload itself remembers where it has been:
seq=0 visit=0 prepare succeeded
seq=1 visit=0 generate succeeded
seq=1 visit=1 generate succeeded
seq=2 visit=0 validate handed_off
seq=2 visit=1 validate succeeded
seq=3 visit=0 report succeeded
final artifact: {'answer': 'sample-1', 'labels': ['prepared', 'sample-0', 'sample-1'], 'selected': 'snapshot:0'}
snapshots: ['prepared', 'sample-0', 'sample-1']
selected: snapshot:0
after pruning sample-0: ['prepared', 'sample-1']
prune refused: cannot prune the selected snapshot
What is worth knowing before you use it:
- It is a payload, not a record.
HistoryArtifactis a decoded application value; it is not the persistedArtifactrow, and snapshot history never replaces the framework's execution ledger, task or visit records. Nothing in the runner reads it to decide where to go next — theHandoff.rewindyou return is still the only thing that moves the pipeline. A plain dictionary stays a plain dictionary: there is no automatic snapshot on task entry or completion. - Snapshots are detached, so nested edits cannot rewrite the past.
state,historyandsnapshot(...)all return deep copies;checkpoint(label, *, metadata=None)appends a snapshot with a stable id (snapshot:0,snapshot:1, …) and a unique label, andsnapshot:is reserved for those ids.with_state(value)replaces the current state without appending a snapshot,restore(id_or_label)replaces it and recordsselected, keeping the whole history so the later stages stay inspectable, andprune(*selectors)is the explicit way to drop snapshots — it raisesValueErrorrather than pruning the selected one, and ids are not reused. - Persisting is the commit's job, not
checkpoint()'s. Callingcheckpoint()inside a task does not touch the store. The runner persists the payload — history included — when it commits the task's output or its control transition, exactly like any other artifact. A crash before that commit loses the in-memory snapshot, and the codec cannot help with that. - The codec needs the class. State and metadata must be JSON serializable, and the versioned
history-v1codec restores snapshots and the registered subclass: give the sameCodecRegistrytopipeline(..., registry=...)andRunner(..., registry=...)(Step 14) and register the subclass withregister_type— an unregistered subclass fails decoding explicitly instead of coming back as aHistoryArtifact. Subclasses inherit the base constructor, so application fields live instate; custom constructors and extra attributes are outside this interface. - History grows with the payload. Every snapshot is kept in full, so a long-lived value can grow with the number and size of them; prune on purpose. For "send this row back" the usual shape is one snapshot per visit plus the states you want to return to, which is what the program above does.
Cheat sheet¶
| I want to… | Do this |
|---|---|
| run one task over a dataset | Runner(store=..., pools=[...]).run(template.map(rows)) |
| k samples per row | template.map(rows, repeats=k) |
| stable ids from my dataset | template.map(rows, key_of=lambda r: r["qid"]) |
| test without touching disk | Runner(store=":memory:") |
| see what happened | report.summary(), report.to_dict(), store.errors() |
| see why it was slow | store.attempts(pipeline_id=...) → duration_ms, decision, leases |
| resume after a crash | runner.run(specs, resume=True) or pyattacker resume -c cfg.yaml |
| re-run results I do not trust | retry_succeeded=True / --retry-succeeded |
| start a pipeline over from the seed, keeping its audit rows | fresh_restart=True / --fresh-restart |
| bound the blast radius | stop_after_failures=N, stop_after_s=T, --limit N |
| cap concurrency per endpoint | Resource.create(..., capacity=N) |
| fail fast instead of queueing | algorithm="immediate" + Retrying(retry_unknown=True) |
| survive a 429 | raise RetryableError(..., error_class="rate_limit", retry_after=...) |
| store big payloads out of the DB | --artifact-backend file:///data/blobs |
| use four processes | --shards 4 --jobs 4, then report/export over the shard files |
| branch inside a step | fanout(task_a, task_b) |
| skip ahead / finish early, on the record | return Handoff.to("report", v) / Handoff.end(v) on a pipeline declared with control={"edges": {...}} |
| send a station back to an earlier one | return Handoff.rewind("generate", chosen_state) on a pipeline declared with control={"rewind": {...}, "max_handoffs": N} |
| restart the whole pipeline from its seed | return Handoff.retry_all() on a pipeline declared with control={"retry_all": [...], "max_handoffs": N} |
| keep named state checkpoints inside a payload | class S(HistoryArtifact), then s.checkpoint("label") / .restore(...) / .prune(...), registered in both registries |
| make my code usable from YAML | no plugin: use: my_pkg.tasks:my_task; with an entry point in pyattacker.tasks: use: my_task |
Every class and function, with signatures and parameter tables: docs/reference.md.
Troubleshooting¶
"My task takes three arguments." Tasks are unary: (value) or (value, ctx). Put extra state in a
factory closure (def make_task(model): @task(...) async def t(value, ctx): ...; return t) — this is the
same pattern Step 6 uses.
"Resume re-ran the whole pipeline." The store was almost certainly written with journal: summary,
which keeps digests but no payloads, so the checkpoint cannot be decoded. Look for a
pipeline.checkpoint_missing event. Use journal: full (the default).
"Nothing ran, there are no rows." Every pipeline was skipped because it had already succeeded — see
report.skipped. That is the intended behaviour, including for a second identical run.
"A pipeline failed with unknown." The exception was not classifiable (for example
ResourceUnavailable, or a bare Exception). Either map it — raise RetryableError(...) — or set
retry_unknown=True if you really want to retry anything.
"It hangs with no output." A selector matches no resource and the algorithm is wait; or a task holds
a lease from a pool while asking the same pool for another (look for an acquire.suspected_deadlock
event); or an await in your own code never returns. timeout_s= on the task and timeout= on the
acquire turn both become errors.
"My resource never comes back." It does — check leases_leaked and the lease.leaked event: you used
await ctx.acquire_lease() without releasing. Use async with, and set strict_leases=True in CI so this
fails loudly.
"The store file is busy / one process is not enough." SQLite allows one writer. Shard into several processes with their own stores (Step 12) instead of pointing several runs at one file.
"Where did my artifact bytes go?" If journal: summary or a null backend is set, only digests are
kept. Otherwise check blob_ref: the payload may be in a file backend, which is transparent on read.
"A run took 30 s in a retry backoff and I lost concurrency." You did not: the pipeline is parked in the
delay queue and the worker took other work. runner.stats()["delayed_pipelines"] shows how many are
parked right now.
"A backward pipeline fails at build time, or dies with a spent budget." control.max_handoffs is
required — and must be a positive integer, so 0, "3", 3.0 and True are refused — as soon as
rewind or retry_all is declared. It is consumed by every nonterminal transfer and survives a resume,
so a pipeline that failed because the budget ran out needs fresh_restart=True / --fresh-restart:
resume=True alone replays the same fatal error. A row still marked running after a hard kill is only
claimed with resume=True; without it the run skips it instead of forking a traversal.
"Handoff.rewind raises a FatalError." The target must be a declared, strictly earlier task, named by
a unique task name or by its seq. Self-rewind, end, and a destination that the pipeline did not declare
are authoring mistakes, so they are fatal and never retried; retry_all likewise needs its source in
control.retry_all and accepts no value.
Where to look next¶
Looking for a specific feature rather than a whole document? See Find what you need near the top.
| Resource | What is in it |
|---|---|
docs/reference.md |
every public class and function: signatures, parameters, examples |
docs/cli.md |
every subcommand and flag, exit codes, the config file reference |
docs/design.md |
conceptual model, six invariants, lease contract, data model, tradeoffs |
README.md |
the compact tour: scheduling guarantees, sharding, plugins, out of scope |
examples/quickstart.py |
the SDK in 60 lines, with a resume round |
examples/llm_eval/ |
the full evaluation, two pipeline shapes, measured checkpoint granularity |
examples/sharded.py |
one dataset across N stores, then a merged report |
examples/plugin_package/ |
an installable plugin: tasks, an algorithm, a codec |
examples/qa_eval.yaml |
the declarative path, end to end |