Skip to main content

send — dynamic fan-out

Normative: MODEL.md §14.

Static edges and routers pick which nodes run next; every task then reads the same checkpoint state. send adds the missing axis: run one node many times in parallel, each with its own input. It is the map-reduce primitive.

A router may return send(node, input) values, alone or mixed with plain node names:

import { graph, channel, START, END, send, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";

// Stand-in worker service — swap for your real per-item work.
const Shout = { process: (item: string) => item.toUpperCase() };

/** Fan-out state — items in, one appended result per worker. */
const FanState = {
items: channel.lastWrite<string[]>([]),
results: channel.append<string>(),
} satisfies ChannelMap;

type FanState = StateOf<typeof FanState>;

/** A worker task's input is its OWN send payload, not the channel state. */
type WorkerInput = { item: string };

const g = graph("fanout", FanState)
.node("fanout", (state) => ({})) // the router below fans out from here
.node("worker", (input: WorkerInput, ctx) => ({ results: [Shout.process(input.item)] }))
.node("collect", (state) => ({ results: [`collected ${state.results.length}`] }))
.edge(START, "fanout")
// one worker task per item, each with its OWN input (§14 send)
.router("fanout", (state) => state.items.map((item) => send("worker", { item })))
// fan-in is just a superstep boundary: collect runs once, after all workers reduce
.edge("worker", "collect")
.edge("collect", END)
.compile();

const { status, state } = await run(g, { items: ["red", "green", "blue"] });
console.log(status, state.results); // "done" [ "RED", "GREEN", "BLUE", "collected 3" ]

Semantics​

  • Each send(node, input) schedules one task for node in the next superstep. N sends to the same node are N distinct parallel tasks.
  • That task's ctx.state is the send input, not the channel state — the worker sees exactly what the mapper handed it. It still writes updates that reduce into the graph's channels, so workers fan results back in through an append channel.
  • input must be JSON-serializable: it is written into the checkpoint's next, so a resumed run re-dispatches the same fan-out.
  • Task identity disambiguates sends: the Nth send to a node has a distinct task id (and its own journal), so a pause or a step inside one fan-out branch never collides with another.
  • Fan-in is just a superstep boundary: a downstream node with an edge from worker runs once, after all workers reduce, seeing the collected channel.

Fanning out after a node runs​

When the fan-out set is only known after a node's work, return the sends in a command goto:

import { graph, channel, START, END, send, command, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";

// Stand-in — the work that decides the fan-out set at run time.
const Planner = { split: (topic: string) => topic.split(" ") };

/** Fan-out state — a topic in, one appended result per discovered item. */
const FanState = {
topic: channel.lastWrite<string>(""),
results: channel.append<string>(),
} satisfies ChannelMap;

type FanState = StateOf<typeof FanState>;

type WorkerInput = { item: string };

const g = graph("late-fanout", FanState)
.node("plan", (state, ctx) => {
const items = Planner.split(state.topic); // fan-out set known only now
return command({ goto: items.map((item) => send("worker", { item })) });
})
.node("worker", (input: WorkerInput, ctx) => ({ results: [input.item.toUpperCase()] }))
.edge(START, "plan") // "plan" has no static outgoing edge — it always returns a goto
.edge("worker", END)
.compile();

const { status, state } = await run(g, { topic: "red green blue" });
console.log(status, state.results); // "done" [ "RED", "GREEN", "BLUE" ]