Cookbook
Common, copy-pasteable patterns. The full rules behind each are on
the #[workflow] and #[container]
pages.
Data flow & shape
- Sequential vs. parallel
- Reuse a multi-step workflow as a building block
- Inline one step’s output into another
- Fan-out over a list
- Conditionals
- Pass only one field of a struct
- Force a sequential execution order
Artifacts & data sharing
- Share data between steps without a dependency
- Share data and keep a strict order
- Pass a large value between steps
Resilience & lifecycle
- Per-task hooks
- Retry with backoff
- Timeouts
- Whole-workflow cleanup
- Mutual exclusion across runs
- Throttle pods per workflow / per DAG
Pod placement & access
- Pin a single pod (image, service account, node)
- Pin every step in a workflow to specific nodes
- Tolerate node taints and steer with affinity
- Reach a podSpec field athena has no attr for
- Pull images from a private registry
- Inject an Argo built-in variable as a parameter
- Pull a Kubernetes Secret as an env var
- Reuse setup across containers
- Set up tracing once for every container
- Async
#[container]fns - Share a PVC across containers in a workflow
Sequential vs. parallel
Edges come from data, not statement order. Independent calls run in parallel; a shared input creates the dependency:
#[workflow]
fn pipeline() {
let a = ingest("src".to_string()); // a and b are independent:
let b = probe(); // they run in parallel
combine(a, b); // depends on BOTH, joins them
}
Need a strict order without a real data dependency? See Force a sequential execution order.
Reuse a multi-step workflow as a building block
A #[workflow] with a return type can be consumed exactly like a
container: the parent gets the workflow’s terminal output as a value.
Build pipelines out of smaller pipelines:
#[workflow]
fn sub(seed: String) -> String {
let f = fetch(seed);
transform(f, 7) // tail call is this workflow's return
}
#[workflow]
fn parent() {
let r = sub("seed".to_string());
publish(r);
}
Inline one step’s output into another
foo(bar()) runs bar as its own task and feeds its output straight
into foo. Shorthand for let x = bar(); foo(x);:
#[workflow]
fn pipeline() {
publish(transform(fetch("u".to_string()), 7));
}
Recursive: foo(bar(baz())) works the same way.
Fan-out over a list
#[workflow]
fn batch() {
let items = make_list(); // -> Vec<String>
let out = items.fan_out(|x| caps(x, "!".to_string()));
summarize(out); // out: Vec<String>
}
caps runs once per element of items; out is the aggregated
Vec, consumed like any output.
Conditionals
Real if / else / else if; a value-if selects the taken branch:
#[workflow]
fn gated() {
let n = decide("hello".to_string());
let chosen = if n > 3 { left(n) } else { right(n) }; // value-if
if n == 0 {
note("zero".to_string());
} else {
note(chosen);
}
}
Conditions are a closed grammar (== != < <= > >=, && || ! over
bindings / inputs / a.field / literals / nested calls).
Pass only one field of a struct
a.field (or a.field.sub) wires only that field to the next task:
#[derive(serde::Serialize, serde::Deserialize)]
struct Meta { id: String, n: i64 }
#[container] fn make_meta() -> Meta { Meta { id: "abc".into(), n: 7 } }
#[container] fn use_id(id: String) { println!("id={id}"); }
#[workflow]
fn pipeline() {
let m = make_meta();
use_id(m.id); // only `id` is wired through
}
Named fields only (no a.0 / a[i]). The compiler checks that the
field exists and matches the consumer’s type.
Force a sequential execution order
Two ways:
-
Thread a return value through. Any return value creates a real data dependency, so the consumer waits for the producer:
#[workflow] fn pipeline() { let token = step_a(); // -> String step_b(token); // can't start until step_a returns } -
Use
stepsmode. The default#[workflow]body is a DAG (edges from data deps). Addingstepsemits a sequential template instead, one statement per group:#[workflow(steps)] fn pipeline() { let p = prepare("seed".to_string()); finalize(p); }Same body, different shape on the wire.
Share data between steps without a dependency
A producer and consumer that share only an S3 key. No ordering, no DAG wiring:
#[container]
fn produce() { cargo_athena::save_artifact_str!("report", "hello"); }
#[container]
fn consume() {
let r = cargo_athena::load_artifact_str!("report");
println!("{r}");
}
A missing object is an error at runtime for the consumer.
Share data and keep a strict order
The recipe above has no ordering. To chain artifact-producing containers explicitly, bridge them with a return value: the artifact key stays a literal, and the return-value gives Argo the edge it needs:
#[container]
fn produce() -> String {
cargo_athena::save_artifact_str!("report", "hello");
"ok".to_string() // return value creates the edge
}
#[container]
fn consume(seq: String) {
let r = cargo_athena::load_artifact_str!("report");
println!("seq={seq}: {r}");
}
#[workflow]
fn pipeline() {
let token = produce();
consume(token); // edge: produce must finish first
}
Pass a large value between steps
A plain return goes inline through Argo, which is fine for small
JSON. For payloads measured in tens of KB or more, or any binary
blob, wrap the return in Artifact<T> and the value flows through
your bucket instead. Wiring is unchanged:
use cargo_athena::{container, workflow, Artifact};
#[container]
fn make_report() -> Artifact<Vec<u8>> {
Artifact::new(build_pdf()) // big binary
}
#[container]
fn ship(r: Artifact<Vec<u8>>) {
upload(r.into_inner());
}
#[workflow]
fn pipeline() {
let r = make_report();
ship(r); // looks like any binding-to-arg
}
When to pick which:
- Plain
Tfor small structured values - configuration, IDs, counts, modest JSON. Easy to see in the Argo UI. Artifact<T>for large or binary returns. No size cliff to worry about, but the value isn’t inspectable from the workflow status without downloading the object.save_artifact!/load_artifact!(the two recipes above) for fixed, known S3 keys where the producer and consumer can be wired separately or out of band.Artifact<T>is the DAG-wired sibling for the common one-producer/one-consumer case.
Per-task hooks
.continue_on / .on_success / .on_failure / .on_error /
.on_exit fire for one specific task:
#[workflow]
fn resilient() {
let raw = fetch("u".to_string()).continue_on(failed, error);
transform(raw, 9)
.on_failure(alarm)
.on_exit(cleanup); // runs when *this task* finishes
}
For a single hook that runs once when the whole workflow ends, see Whole-workflow cleanup below.
Retry with backoff
A flaky step retries itself:
#[container(retry(limit = 3, policy = "OnError", backoff = "30s"))]
fn fetch(url: String) -> String { /* … */ "ok".into() }
limit is required (unlimited for no cap); policy is one of
Always, OnFailure, OnError, OnTransientError; backoff is an
int (seconds) or a humantime string. Works on #[workflow] too. Full
field reference: #[container].
Timeouts
Three knobs for three scopes; stack as many as you need:
#[container(
timeout = "5m", // counts Pending time
pod_running_timeout = "2m", // only counts time Running
)]
fn long_step() { /* … */ }
#[workflow(active_deadline_if_root = "1h")] // whole-workflow cap (root-only)
fn pipeline() { /* … */ }
Full distinctions: Timeouts.
Whole-workflow cleanup
on_exit_if_root runs once when the workflow finishes, but only for
the workflow you actually submit:
#[workflow(on_exit_if_root = teardown)]
fn pipeline() { /* … */ }
When pipeline is run directly, teardown fires at the end (either
argo submit --from workflowtemplate/pipeline or cargo athena submit pipeline works). When pipeline is embedded as a sub-step
of a bigger run, its own on_exit_if_root stays inert; submit it
directly if you want the hook.
This is distinct from the per-task .on_exit(t) builder, which
always fires for that one task.
Mutual exclusion across runs
Block two runs of a workflow from racing each other, or serialize one expensive step within a run:
// Only one "deploy" workflow at a time across the namespace:
#[workflow(mutexes_if_root = [{ name = "deploy-" + env }])]
fn pipeline(env: String) { /* … */ }
// Serialize one expensive step; the rest of the DAG fans out normally:
#[container(mutexes = [{ name = "shard-" + shard }])]
fn writer(shard: String) { /* … */ }
Two tiers, picked by reach:
mutexes_if_rootis held for the whole submitted run. Root-only: inert when this WT is embedded as a sub. The standard “one of these workflows at a time” knob.mutexesis held just while the template’s node is running. Fires anywhere the template is invoked (root or nested).
Each entry is { name = …, namespace = … }; namespace is optional
(defaults to the workflow’s own). Both fields accept "lit" + arg
injection. Full details: #[workflow] Mutexes.
Throttle pods per workflow / per DAG
Cap how many pods Argo runs at once, either for the whole submitted run or just under one dag/steps template:
// Cap the whole run to 4 concurrent pods; cap THIS DAG to 2 of its
// own direct children at a time (nested templates don't count):
#[workflow(parallelism = 2, parallelism_if_root = 4)]
fn pipeline() { /* … */ }
Two tiers, picked by reach:
parallelism_if_rootcapsWorkflowSpec.parallelism, applied to every pod in the submitted run. Root-only: inert when this WT is embedded as a sub-workflow.parallelismcapsTemplate.parallelism, applied only to children scheduled DIRECTLY by this dag/steps. Pods from nested templates aren’t counted. Fires anywhere the template is invoked.
Both are literal i64 and must be > 0 (Argo’s CRD enforces
Minimum=1; the *int64 schema rejects substituted strings at
admission, so neither field supports argument injection).
Pin a single pod (image, service account, node)
Static, or with a container argument spliced in:
#[container(
image = "ghcr.io/acme/heavy:" + tag,
service_account = "athena-" + tenant + "-runner",
node_selector = { "kubernetes.io/arch" = "amd64",
"disktype" = profile.disk },
)]
fn heavy(tag: String, tenant: String, profile: Profile) -> String { tag }
Operands are an argument or a named struct field of one, and must
be String / &str / number. See
Parameter injection.
Pin every step in a workflow to specific nodes
#[workflow(
boundary_node_selector = { // literal-only
"kubernetes.io/arch" = "amd64",
},
node_selector_if_root = { // injection allowed
"tier" = "platform",
"env" = "prod-" + env,
},
)]
fn pipeline(env: String) { /* ... */ }
boundary_node_selectorcovers pods whose immediate enclosing dag/steps is this template. Does NOT cascade through nested sub-workflows. Literal only. If you want a value that depends on an argument, usenode_selector_if_root.node_selector_if_rootis the default for every pod in the submitted run that doesn’t have a tighter override. Root-only: inert when this WT is embedded as a sub. Values accept"lit" + arg/"lit" + arg.fieldinjection.
Tolerate node taints and steer with affinity
Most clusters taint GPU / spot / dedicated nodes; pods need tolerations to schedule there:
#[container(tolerations = [
{ key = "nvidia.com/gpu", operator = "Exists", effect = "NoSchedule" },
{ key = "spot", operator = "Equal", value = "true", effect = "NoSchedule" },
])]
fn train(input: String) { /* ... */ }
For “every pod gets these tolerations,” use the root-level version:
#[workflow(tolerations_if_root = [
{ key = "dedicated", operator = "Equal", value = "ml-team", effect = "NoSchedule" },
])]
fn pipeline() { /* ... */ }
Affinity is a deeply-nested K8s shape; athena keeps it as an opaque YAML/JSON string so you write what K8s already documents:
#[workflow(affinity_if_root = r#"
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-pool
operator: In
values: [gpu-a100]
"#)]
fn pipeline() { /* ... */ }
Embed {{workflow.parameters.X}} substitutions verbatim if you need
dynamic values at root scope. Same goes for the container-level
affinity = "...".
The boundary tier covers the case “all pods that live inside this
specific dag inherit these scheduling constraints” (boundary_tolerations
and boundary_affinity, mirroring boundary_node_selector). Pods that
set their own override the inheritance; pods that don’t pick up the
boundary’s values. Literal only at this tier: use the matching
*_if_root form for values that depend on an argument.
Reach a podSpec field athena doesn’t have an attr for
pod_spec_patch = "<json|yaml>" is the universal escape hatch: Argo
strategic-merges the patch onto the rendered pod just before
submission. Use it for any K8s field cargo-athena doesn’t lift to a
first-class attr (CPU/memory limits, init containers, sidecars,
fsGroup, runtimeClassName, …).
// Per-container patch (pins this template's pod resources).
#[container(pod_spec_patch = r#"{
"containers":[{"name":"main","resources":{
"limits":{"cpu":"500m","memory":"512Mi"},
"requests":{"cpu":"100m","memory":"128Mi"}
}}]
}"#)]
fn heavy(input: String) { /* ... */ }
// Whole-workflow patch (every pod in the run).
#[workflow(pod_spec_patch_if_root = r#"{
"terminationGracePeriodSeconds":120
}"#)]
fn pipeline() { heavy("x".to_string()); }
The string accepts the usual "lit" + arg injection grammar, e.g.
pod_spec_patch = r#"{"containers":[{"name":"main","resources":{"limits":{"cpu":""# + cpu + r#""}}}]}"#.
Injection works at both the container and the root tier.
athena does NOT validate the patch shape: that is the trade-off for “any field.” Argo and the K8s API reject malformed input at submit / admission time.
Pull images from a private registry
Bind one or more imagePullSecrets (Secret names in the workflow’s
namespace) to every pod in the run:
#[workflow(image_pull_secrets_if_root = ["regcred", "harborcred"])]
fn pipeline() { build(); deploy(); }
K8s / Argo expose this only at workflow scope; if you need a
per-container override (rare), reach for pod_spec_patch.
Inject an Argo built-in variable as a parameter
#[inject("<argo expression>")] on a function arg fills it from Argo’s
substitution at pod-creation, bypassing inputs.parameters entirely.
#[container]
fn smart_retry(
payload: String, // normal caller arg
#[inject("{{retries}}")] attempt: i64, // bare numeric
#[inject("\"{{pod.name}}\"")] pod_name: String, // quoted string
) {
println!("attempt {attempt} on pod {pod_name} with payload={payload}");
}
#[workflow]
fn pipeline() {
// The workflow body passes only the caller-visible param. The two
// inject args are filled by Argo in the pod.
smart_retry("hello".to_string());
}
The macro does NOT validate the expression: it’s piped to Argo verbatim. You own:
- The variable’s scope.
{{retries}}only resolves inside aretry(...)strategy;{{tasks.X.outputs.Y}}only resolves inside a DAG context. - JSON wrapping. Numeric /
booltypes want a bare expression ({{retries}}→3);Stringtypes want explicit quotes ("\"{{workflow.name}}\""→"wf-abc").
Wrong wrapping or out-of-scope refs fail the run with a clear “deserialize container input” message: a useful signal that the value didn’t substitute.
Pull a Kubernetes Secret as an env var
secret!("secret-name", "key") declares a Secret env on the
container and reads it back at runtime as a String. secret_opt!
is the no-panic flavour (returns Option<String>):
#[container]
fn fetch(url: String) -> String {
let token = cargo_athena::secret!("api-tokens", "api");
let trace = cargo_athena::secret_opt!("debug-creds", "trace");
/* … use them … */
String::new()
}
secret_opt! skips the env when the secret/key is missing, instead
of failing pod start.
Reuse setup across containers
A #[fragment] is just a normal Rust function that runs inside the
calling container. It can take arguments, do real work, and return a
value, exactly like any helper. Its only superpower: every host! /
artifact / secret! declaration it makes is added to each container
that transitively calls it.
So you can wrap “open a database connection” once and hand the connection back to every container that needs one:
#[fragment]
fn open_db() -> DbHandle {
let user = cargo_athena::secret!("db-creds", "user");
let pass = cargo_athena::secret!("db-creds", "password");
let ca = cargo_athena::host!("/secrets/db"); // host dir -> &Path
DbHandle::connect(&user, &pass, &ca)
}
#[container]
fn migrate() {
let db = open_db(); // mounts + env land on this pod
db.run_migrations();
}
#[container]
fn nightly_audit() {
let db = open_db(); // …and this one
let n = db.flag_anomalies();
println!("flagged {n}");
}
Every container that calls open_db() automatically gets the
database Secret and the host mount wired into its pod. The values
come back through open_db()’s return, so a caller never names an
env var or mount path itself, and doesn’t have to know what’s inside
the fragment.
Set up tracing (or any pod-only init) once for every container
Your workflow binary runs in two worlds (see Core
Concepts), so
tracing_subscriber::fmt().init() in main() would fire on every
local cargo athena call too: harmless for stdout logging, but a
footgun for OTLP exporters or anything that dials out or costs money.
Gate it with cargo_athena::is_container_run() so the setup only
runs in-pod:
fn main() {
// None for `cargo athena emit` / `ls` / etc.; Some(_) in-pod.
// The returned guard (if any) drops at end of main(), so any
// tracing/OTLP flush you stick on Drop fires after the body.
let _otel = cargo_athena::is_container_run().then(|| {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
OtelFlushGuard::new()
});
cargo_athena::entrypoint!(MyRoot);
}
The pattern works for anything you only want in-pod: a Prometheus
push gateway, a Sentry init, an audit-log open. Per-container span
scoping (one tracing::info_span! per body) isn’t covered by this
pattern – if you need it, open an issue.
Async #[container] fns
Mark a container async fn and the macro wraps the body in a
current-thread tokio runtime. Enable the tokio feature on
cargo-athena to opt in; tokio is re-exported:
// Cargo.toml: cargo-athena = { …, features = ["tokio"] }
#[container]
async fn fetch(url: String) -> String {
cargo_athena::tokio::time::sleep(std::time::Duration::from_millis(10)).await;
format!("data-from:{url}")
}
#[workflow] bodies are statically analyzed, so
#[workflow] async fn is a compile error.
Share a PVC across containers in a workflow
Declare a PVC as a unit struct, then mount it with pvc!(Type)
inside any #[container] / #[fragment]. Two flavors, picked by
who owns the PVC’s lifetime:
// Per-workflow-run scratch space. Argo creates the PVC at workflow
// start and deletes it at workflow end (Argo's
// `WorkflowSpec.volumeClaimTemplates`).
#[cargo_athena::ephemeral_pvc(
size = "10Gi",
access_modes = ["ReadWriteMany"],
)]
pub struct BuildCache;
// Reference to a pre-existing PVC (managed out of band). athena
// never creates or deletes it.
#[cargo_athena::external_pvc(claim_name = "shared-data-pvc", read_only = true)]
pub struct SharedData;
#[container]
fn build() {
let cache: &Path = cargo_athena::pvc!(BuildCache);
std::fs::write(cache.join("output.bin"), b"hello").unwrap();
}
#[container]
fn analyze() {
// Same type → same PVC. Two pods sharing a `ReadWriteMany`
// ephemeral see each other's files.
let cache: &Path = cargo_athena::pvc!(BuildCache);
let bytes = std::fs::read(cache.join("output.bin")).unwrap();
println!("read {} bytes", bytes.len());
}
#[workflow]
fn pipeline() {
let _ = build();
analyze();
}
Two consumers sharing the same #[ephemeral_pvc] concurrently
need ReadWriteMany. ReadWriteOnce is fine when only one pod
ever mounts it at a time, but a parallel fan-out over RWO will
fail the second pod’s volume attach.
The mount path is opaque (/athena/pvcs/<hash>) and stable across
emit and run; always use the returned &'static Path value and
never hard-code the path.
v1 caveat: every #[ephemeral_pvc] linked into your binary
lands on every emitted WorkflowTemplate’s volumeClaimTemplates.
Argo creates ALL of them per run, even if the submitted workflow
doesn’t reach them. Keep one workflow per binary and define each
#[ephemeral_pvc] near its consumer to avoid cross-workflow
PVC churn. See the #[container] reference for details.
Pitfalls
- Fan-out a value to two consumers needs
.clone(). The body is faithful Rust; each consumer gets its own copy of the upstream value, so the explicit clone is correct. - Workflow bodies are strict. Loops,
match, and arbitrary expressions are compile errors by design, so a step is never silently dropped.if/else/else if, nested calls, and the builder /fan_outchain are supported. - Any string value is safe.
t("no")works and aString"7"stays a string, not a number, because every parameter value is JSON-encoded. (An argument name that reads as a YAML bool is a separate matter and is rejected at compile time; see Troubleshooting.)
Hitting an actual error? See Troubleshooting.