Canvas Primitives
chain / group / chord — shorthand composition for common task pipeline and workflow shapes.
chain / group / chord — shorthand composition for common task pipeline and workflow shapes.
flexiq's canvas gives you three shorthand primitives for common pipeline shapes: chain (sequential), group (parallel), and chord (parallel with a callback) — composing task calls without hand-wiring dependencies yourself.
Canvas primitives are in-thread orchestrators, independent of
DAG workflows — no
persistent run row in storage, no workflow engine required. chain
enqueues each step and blocks on its result before dispatching the next;
group enqueues every member in parallel; chord runs a group then a
callback. See "Canvas vs DAG workflows" below for when to reach for each.
chain, group, and chord are methods on the same
workflow builder you use for
everything else — they just add .step() calls with the after dependency
wiring done for you. The result is a normal DAG workflow you .submit()
like any other.
Canvas.chain, Canvas.group, and Canvas.chord build a Workflow from a
few Canvas.link(name, task, payload) steps — the same DAG a hand-written
builder chain would
produce. Submit the result like any other workflow, and keep adding steps to
it with the regular builder if you need more.
Nothing in flexiq or in flexiq-workflows is named chain, group or
chord, and there is no canvas type to import. The shapes are real; the
shorthand is not.
What stands in for them is already on FlexiQ: enqueue and
enqueue_batch, plus .depends_on([…]) on any call. Every example on this
page is those three, arranged differently.
flexiq's canvas mirrors Celery's chain / group / chord / .s() /
.si() / chunks / starmap, with a few deliberate differences:
| Celery | flexiq | Difference |
|---|---|---|
chain(...).apply_async() | chain(...).apply(queue) | .apply() here submits the canvas; pass the queue. |
chain(...).apply() (eager) | — | flexiq has no eager .apply(); it always enqueues for workers. |
AsyncResult.get(timeout=) | job.result(timeout=) | Same idea, different name. |
group(...) → GroupResult | group(...).apply(queue) → list[JobResult] | flexiq returns a plain list of handles — iterate and call .result() on each. |
.s() / .si() | .s() / .si() | Identical (mutable vs immutable signature). |
The biggest gotcha: in Celery, .apply() executes the canvas locally and
synchronously. In flexiq, .apply(queue) enqueues it for workers,
and you await results with .result(). There is no eager local execution.
Every shape below, and the call that replaces it:
| Shape | Written as |
|---|---|
| chain | one enqueue per step, each .depends_on([&prev.id]) |
| group | one enqueue_batch(vec![…]) — same task, many calls |
| chord | enqueue_batch for the members, then enqueue a callback that depends on all their ids |
| chunks | slice the input yourself, one call per slice, then enqueue_batch |
| starmap | a call per tuple; the macro already gives each task a typed call(..) |
Two limits shape all of them, and neither is a gap you can route around.
depends_on orders jobs but passes nothing between them — every call carries
the arguments it was built with. And a result is not readable from here:
job.result is an Option<Vec<u8>> with no typed reader exposed. Where a shape
wants a value to flow, the producing task has to write it somewhere the consumer
can read, and the consumer gets the key.
Each primitive is composed from small step descriptors:
A Signature wraps a task call for deferred execution:
from flexiq import chain, group, chord
sig = add.s(1, 2) # Mutable — receives previous result as first arg
sig = add.si(1, 2) # Immutable — ignores previous resultA CanvasStep names a step and its task; it accepts every .step() option
except after, which the shorthand manages for you:
{ name: "extract", task: "extract" }
{ name: "load", task: "load", maxRetries: 5 }Canvas.link(name, task, payload) binds a step name to a registered task and
its payload:
Canvas.link("extract", extractTask, 1)
Canvas.link("transform", transformTask, 2)There is no canvas in Rust — no chain, group or chord, in the shell or in
the workflows crate. The building block is the TaskCall that
#[flexiq::task] generates, and the shapes on this page are wired by hand with
depends_on:
// `<task>::call(..)` mirrors the task's own signature and carries its options.
let fetch_users = extract::call("https://api.example.com/users".to_string());
let bulk_load = load::call().max_retries(5);Name the bindings something other than the task. #[flexiq::task] also emits a
unit struct called extract, so let extract = … is read as a struct pattern
rather than a new binding, and the compiler rejects it.
depends_on holds a job until the jobs it names have completed, which covers
all three shapes below. It does not pass a result along — every call carries
the arguments it was built with. For a persisted DAG with per-node status
rows, build a dagron_core::DAG and submit it with
flexiq::workflows::lifecycle::submit_workflow, which wires the same
depends_on chains for you.
Run steps in sequence:
result = chain(
extract.s("https://api.example.com/users"),
transform.s(),
load.s(),
).apply(queue)
print(result.result(timeout=30))queue.workflows
.define("etl")
.chain([
{ name: "extract", task: "extract" },
{ name: "transform", task: "transform" },
{ name: "load", task: "load", maxRetries: 5 },
])
.submit();
// extract → transform → loadWorkflow etl = Canvas.chain("etl",
Canvas.link("extract", extractTask, 1),
Canvas.link("transform", transformTask, 2),
Canvas.link("load", loadTask, 3));
queue.submitWorkflow(etl);
// extract → transform → loadlet extracted = queue.enqueue(extract::call("https://api.example.com/users".to_string()))?;
let transformed = queue.enqueue(transform::call().depends_on([&extracted.id]))?;
let loaded = queue.enqueue(load::call().max_retries(5).depends_on([&transformed.id]))?;
// extract → transform → loadA dependency counts as satisfied only once it reaches Complete, so a step
that fails leaves the rest of the chain pending rather than failing it — check
for stalled work, don't assume a failure propagates.
Each step's return value flows automatically into the next via .s(). Use
.si() when a step should not receive the previous result:
chain(
step_a.s(input_data),
step_b.si(independent_data),
step_c.s(),
).apply(queue)A chain step is an ordinary workflow step wired with after — it does
not automatically receive its predecessor's return value, and args
are fixed at build time. To act on an upstream result, aggregate it with
fan-out / fan-in instead
of a plain chain step.
Pass { after } to chain a canvas block onto an existing step.
Like any workflow step, a chain link does not automatically receive its predecessor's return value — pass the payload explicitly, or read upstream results via the workflow context.
Building the chain link by link means the producer has to be enqueued before its
successor — you need extracted.id to write the next call. That is a plain data
dependency, not a ceremony, and it falls out of the code:
let mut previous: Option<String> = None;
for url in urls {
let call = fetch::call(url);
let call = match &previous {
Some(id) => call.depends_on([id]),
None => call,
};
previous = Some(queue.enqueue(call)?.id);
}Storage checks a dependency at enqueue time, so naming an id that does not
exist, one that is already Dead or Cancelled, or one in another namespace
fails the enqueue call outright rather than producing a job that can never
run.
A step that merely fails is different: it stays in the table, its dependents
stay Pending, and the chain simply stops. Nothing propagates the failure
downstream and nothing cancels the tail — watch for stalled work instead of
waiting for an error.
Run steps in parallel:
jobs = group(
process.s(1),
process.s(2),
process.s(3),
).apply(queue)
results = [j.result(timeout=30) for j in jobs]queue.workflows
.define("notify")
.step("prepare", "prepare")
.group(
[
{ name: "email", task: "sendEmail" },
{ name: "sms", task: "sendSms" },
{ name: "push", task: "sendPush" },
],
{ after: "prepare" },
)
.submit();
// prepare → (email | sms | push)Workflow notify = Canvas.group("notify",
Canvas.link("email", sendEmail, message),
Canvas.link("sms", sendSms, message),
Canvas.link("push", sendPush, message));
queue.submitWorkflow(notify);
// email | sms | pushlet jobs = queue.enqueue_batch(vec![
process::call(1),
process::call(2),
process::call(3),
])?;
// process | process | processenqueue_batch inserts every call in one go and returns a Job each; workers
pick them up in parallel with no further wiring. It is generic over one task
type, so a batch is many calls to the same task — a group of three different
tasks is three enqueue calls, which parallelise identically. A call carrying
a debounce window is refused in a batch.
There is no per-batch concurrency cap either. Declare the limit on the task
with #[flexiq::task(max_concurrent = 5)], which caps concurrent runs
cluster-wide.
jobs = group(
*[fetch.s(url) for url in urls],
max_concurrency=5,
).apply(queue)Canvas.group always starts group members as roots. To hang a parallel
group off an existing step, add the steps with the builder instead —
Canvas shortcuts always start from roots:
Workflow wf = Workflow.named("notify")
.step("prepare", prepareTask, 1)
.step("email", sendEmail, message, "prepare")
.step("sms", sendSms, message, "prepare");
// prepare → (email | sms)A group has no root/non-root distinction to work around — depends_on is an
option on each call, so to hang one off an earlier step, give every member the
same predecessor:
let prepare = queue.enqueue(prepare::call(order_id))?;
let fanned = queue.enqueue_batch(vec![
notify::call(order_id, "email".to_string()).depends_on([&prepare.id]),
notify::call(order_id, "sms".to_string()).depends_on([&prepare.id]),
])?;
// prepare → (email | sms)Three different tasks would be three enqueue calls instead, since a batch is
generic over one T: Task. They still run in parallel; the batch buys atomicity
of the insert, not the parallelism.
There is no max_concurrency on a group either, because the cap has nothing to
attach to — nothing records that these jobs are one group. Declare it on the
task and it holds across every caller:
#[flexiq::task(max_concurrent = 5)]
fn fetch(url: String) -> flexiq::Outcome<()> { /* … */ Ok(()) }max_concurrent is a cluster-wide ceiling on concurrent runs of that task.
max_in_flight_per_task is the per-worker sibling, and rate_limit = "100/m"
caps starts per unit of time rather than overlap.
Fan-out with a callback — run tasks in parallel, then run a final task once every member finishes (whether the callback sees the members' results is SDK-specific — see the notes below):
result = chord(
group(
fetch.s("https://api1.example.com"),
fetch.s("https://api2.example.com"),
fetch.s("https://api3.example.com"),
),
merge.s(),
).apply(queue)queue.workflows
.define("report")
.chord(
[
{ name: "q1", task: "queryRegion" },
{ name: "q2", task: "queryRegion" },
],
{ name: "merge", task: "merge" },
)
.submit();
// (q1 | q2) → mergeWorkflow report = Canvas.chord("report",
Canvas.link("merge", mergeTask, 0), // callback
Canvas.link("q1", queryRegion, "east"), // group...
Canvas.link("q2", queryRegion, "west"));
queue.submitWorkflow(report);
// (q1 | q2) → mergelet members = queue.enqueue_batch(vec![
query_region::call("east".to_string()),
query_region::call("west".to_string()),
])?;
let merge = queue.enqueue(
merge::call().depends_on(members.iter().map(|job| job.id.as_str())),
)?;
// (east | west) → mergeThe callback receives its own arguments, not the members' results. Reading a
member's output is not something the shell wraps: job.result is an
Option<Vec<u8>> and there is no typed reader for it. If the callback needs
the members' values, have each member write them where the callback can read
them, and give the callback the key. Every member must reach Complete or the
callback never runs.
The callback receives the list of member results as its first argument —
merge above is called with [result1, result2, result3]. Use an
immutable signature (merge.si()) to opt out.
chord's callback runs after the group but receives its own args — the
members' results are not auto-passed. To aggregate results into one step,
use fan-out / fan-in,
which collects child results into the combiner's argument.
A chord's callback runs after the group but receives its own payload —
the members' results are not auto-passed. To aggregate results into one
step, use fan-out / fan-in,
which collects child results into the combiner's payload.
A dependency counts as satisfied only at Complete, and a member that
exhausts its retries is archived as Dead — which still blocks. The
callback then sits Pending indefinitely: nothing fails it, nothing cancels
it, and no timeout applies, because a job that never dispatches never starts
a clock.
Give members enough max_retries to be worth waiting on, and sweep for
long-Pending callbacks. queue.cancel(&merge.id) is how you retire one.
Because the callback cannot read what the members produced, the aggregation has to happen somewhere both can reach. A run id both sides derive from is usually enough:
let members = queue.enqueue_batch(vec![
query_region::call(run_id.clone(), "east".to_string()),
query_region::call(run_id.clone(), "west".to_string()),
])?;
// Each member writes under `run_id`; `merge` reads them back by the same key.
let merge = queue.enqueue(
merge::call(run_id).depends_on(members.iter().map(|job| job.id.as_str())),
)?;depends_on takes anything iterating into String, which is why &job.id and
job.id.as_str() both work.
Canvas primitives support in-thread saga compensation via
.with_compensation(). When a step fails, flexiq dispatches the
compensators for the steps that already succeeded — no workflow engine
required.
Canvas compensation is in-thread: compensators are enqueued synchronously
during the apply() call that raised. For DAG-level sagas with full
observability and sub-workflow propagation, see the
Sagas guide.
Attach one compensator per chain step. None skips a slot. On failure,
compensators for completed steps run in reverse order:
from flexiq import chain
@queue.task()
def step_a(x: int) -> int:
return charge_payment(x)
@queue.task()
def step_b(x: int) -> str:
return create_order(x)
@queue.task()
def refund(forward_args, forward_kwargs, forward_result):
payment_id = forward_result
payment_api.refund(payment_id)
@queue.task()
def cancel_order(forward_args, forward_kwargs, forward_result):
order_id = forward_result
order_api.cancel(order_id)
result = chain(
step_a.s(100),
step_b.s(),
).with_compensation([refund, cancel_order]).apply(queue)with_compensation requires exactly one entry per chain step (a slot can be
None to skip that step). Only steps that completed are compensated, in
reverse order, one at a time — the failed step itself never ran to completion,
so it has nothing to undo. If step_b fails after step_a succeeded, only
refund (step_a's compensator) is enqueued; cancel_order would run only in
a longer chain where step_b completed and a later step failed.
One compensator per group member. On failure of any member, compensators for the succeeded members dispatch in parallel. The failed member is not compensated:
from flexiq import group
g = group(
debit_account.s(100),
reserve_inventory.s("item-7"),
notify_warehouse.s(42),
).with_compensation([
refund_account,
release_inventory,
cancel_notification,
])
g.apply(queue)chord.with_compensation takes separate group= and callback= arguments:
from flexiq import chord, group
ch = chord(
group(fetch.s("url1"), fetch.s("url2")),
merge.s(),
).with_compensation(
group=[rollback_fetch1, rollback_fetch2],
callback=rollback_merge,
)
ch.apply(queue)Compensation order:
callback='s compensator is accepted by with_compensation() but is not
currently dispatched on callback failure — only the group members are ever
rolled back. Handle callback-side rollback inside the callback task itself
until this gap closes.
Every compensator receives three positional args — the same contract as DAG-level saga compensators:
@queue.task()
def my_compensator(forward_args: tuple, forward_kwargs: dict, forward_result: object) -> None:
# forward_result is the return value of the step being compensated.
...Each compensator is enqueued with a deterministic unique key of the form
canvas_compensation:{run_id}:{slot}, where run_id is a fresh UUID
generated when apply() starts. The dedup guarantee is per-run: it stops
one apply() call's failure handling from double-dispatching the same
compensator slot. It does not carry across separate apply() calls — each
call gets its own run_id, so retrying a whole canvas from scratch (e.g.
calling apply() again after a crash) re-dispatches every compensator.
None to disable compensation# Disables all compensation (equivalent to not calling with_compensation).
chain(a.si(), b.si()).with_compensation(None).apply(queue)
# Disables compensation for one specific slot.
chain(a.si(), b.si()).with_compensation([compensate_a, None]).apply(queue)Canvas steps are ordinary workflow steps, so a CanvasStep accepts the same
compensate option as .step():
.chain([
{ name: "reserve", task: "reserveInventory", compensate: "unreserveInventory" },
{ name: "charge", task: "chargePayment", compensate: "refundPayment" },
])Rollback then follows the regular DAG-level saga rules — reverse-dependency order, driven by the workflow run's own state machine, not a canvas-specific mechanism. See Saga compensation.
Canvas.link() only takes a name, task, and payload — there's no
compensate option on the shorthand itself. For rollback on a canvas-built
step, mix in the regular builder: Canvas.chain(...) (and group/chord)
return a plain Workflow, and Workflow.named(...).step(...) exposes a
.compensate(...) builder method for
saga compensation.
There is no compensate option — not on EnqueueOptions, not on
#[flexiq::task], and nothing walks a failure back down a depends_on chain.
Enqueued jobs are independent rows; one failing leaves the others exactly where
they are.
Rollback lives inside a single task instead, written over durable steps so the undo knows what was actually done:
#[flexiq::task]
fn checkout(order_id: String) -> flexiq::Outcome<()> {
let mut step = flexiq::current_step();
let hold = step.run_keyed("reserve", &order_id, || reserve(&order_id))?;
// The charge's failure is this step's *value*, not an abort — see below.
let charged: Result<Charge, String> = step.run_keyed("charge", &order_id, || {
Ok(charge(&order_id).map_err(|e| e.to_string()))
})?;
if let Err(reason) = charged {
step.run_keyed("unreserve", &order_id, || unreserve(&hold))?;
return Err(flexiq::TaskError::fatal(reason).into());
}
Ok(())
}Two things make that work, and neither is optional.
run_keyed, not run: an unkeyed step is matched by its position in the
recorded sequence, so a compensating branch asks for a step the earlier attempt
never recorded there and the run fails with a divergence instead of undoing
anything. A keyed step is matched by key wherever it sits.
And the forward failure is committed as a value rather than raised. A step whose
body aborts commits nothing and leaves its position outstanding, and the session
refuses to start another step while one is — so the compensator would fail with
started while step 'charge' is still uncommitted rather than run.
Saga compensation covers the shape in full.
from flexiq import chunks, starmap
# Batch processing — split 1000 items into groups of 100
results = chunks(process_batch, items, chunk_size=100).apply(queue)
# Map-reduce pattern
result = chord(
chunks(process_batch, items, chunk_size=100),
merge_results.s(),
).apply(queue)
# Tuple unpacking
results = starmap(add, [(1, 2), (3, 4), (5, 6)]).apply(queue)Node has no chunks / starmap helper — both are just a group of pre-sized
steps. Build the batches in JS and hand them to .group():
// chunks: split items into batches of 100
const size = 100;
const batches = Array.from({ length: Math.ceil(items.length / size) }, (_, i) =>
items.slice(i * size, (i + 1) * size),
);
queue.workflows
.define("batch")
.group(batches.map((b, i) => ({ name: `chunk-${i}`, task: "processBatch", args: [b] })))
.submit();
// starmap: one step per arg tuple
queue.workflows
.define("pairs")
.group([[1, 2], [3, 4]].map(([a, b], i) => ({ name: `add-${i}`, task: "add", args: [a, b] })))
.submit();For dynamic batching driven by an upstream result, use fanOut / fanIn.
Java has no chunks / starmap shortcut — they are a group of fixed steps.
Slice in Java and pass the links to Canvas.group:
List<Canvas.Link> links = new ArrayList<>();
for (int i = 0; i < items.size(); i += 100) {
List<Item> batch = items.subList(i, Math.min(i + 100, items.size()));
links.add(Canvas.link("chunk-" + (i / 100), processBatch, batch));
}
Workflow wf = Canvas.group("batch", links.toArray(new Canvas.Link[0]));
queue.submitWorkflow(wf);For dynamic per-item fan-out, use Step.Builder.fanOut(...) / .fanIn(...).
Neither exists, and neither needs to — both are a Vec<TaskCall<T>> you build
before handing it over:
// chunks: one call per slice of 100
let calls: Vec<_> = items
.chunks(100)
.map(|batch| process_batch::call(batch.to_vec()))
.collect();
let jobs = queue.enqueue_batch(calls)?;
// starmap: one call per tuple, unpacked by the call signature itself
let jobs = queue.enqueue_batch(
vec![(1, 2), (3, 4), (5, 6)]
.into_iter()
.map(|(a, b)| add::call(a, b))
.collect(),
)?;Starmap's unpacking is free here: add::call(a, b) takes the task's own
parameters, so a tuple arity that does not match is a compile error rather than
a runtime one. Chunking is just slice::chunks.
Dynamic batching — where the batch size comes from an upstream result — has no
equivalent. Enqueue the follow-up work from inside the task that computed it,
using a FlexiQ handle the process already holds.
| Feature | Canvas | DAG Workflows |
|---|---|---|
| Setup | No imports needed | from flexiq.workflows import Workflow |
| Topology | Linear chains, flat groups | Arbitrary DAGs |
| Fan-out | Static (known at build time) | Dynamic (from return values) |
| Conditions | None | on_success, on_failure, always, callables |
| Error handling | Per-task retries only | Workflow-level strategies |
| Saga compensation | chain / group / chord | DAG-level, with sub-workflow propagation |
| Approval gates | No | Yes |
| Sub-workflows | No | Yes |
| Incremental runs | No | Yes |
| Status tracking | Per-job only | Per-workflow + per-node |
| Visualization | No | Mermaid / DOT |
Use canvas for quick one-off pipelines. Use DAG workflows for production pipelines that need monitoring, conditions, or complex topologies.
Canvas is not a separate engine in Node — chain / group / chord are
methods on WorkflowBuilder that produce an ordinary DAG workflow. Reach for
conditions, gates, or sub-workflows on the same builder when you need them; see
Workflows.
Canvas.chain / group / chord return a plain Workflow you can keep
extending with the builder (conditions, gates, sub-workflows) — canvas and DAG
are the same object. See Workflows.
The choice is between depends_on on plain jobs and the flexiq-workflows
crate, which you opt into with features = ["workflows"]:
depends_on | flexiq::workflows | |
|---|---|---|
| Setup | Nothing — it is an enqueue option | An extra feature and an extra storage handle, WorkflowStorageBackend |
| Topology | Any DAG you can name ids for | Any DAG, from a serialized dagron_core::SerializableGraph |
| Builder | None needed | None provided — you assemble SubmitStaticWorkflowRequest yourself |
| Status tracking | Per job | A WorkflowRun row plus a WorkflowNode per step |
| Result passing | None | None — nodes carry the payloads you gave them |
| Re-runs | Re-enqueue | cache_hit_nodes replays a node's recorded result_hash without running it |
| Deferred nodes | n/a | deferred_node_names creates the row and leaves the job to you |
lifecycle::submit_workflow is the entry point, and it is deliberately
low-level: it takes the serialized graph, a step_metadata entry and a
pre-encoded payload for every node that gets a job, and refuses the whole call
if one is missing rather than leaving half a run behind. It returns a
WorkflowRunHandle — run_id and definition_id — which is what you poll on.
Reach for it when you want the run rows. For a pipeline you only need to
order, depends_on costs nothing and adds no crate.