feat(nodes): per-item fan-out — run an array of work concurrently - #26
Conversation
…ncurrently `config.concurrency` bounds how many items a per-item node runs at once (unset/1 = sequential as before, n = bounded, 0/"all" = unbounded), and `config.on_item_error` decides what a failing item does to the batch. The policy default follows the execution shape: a fan-out collects (one bad item must not discard the batch) while a sequential run keeps failing fast, because tool_call/http_request/memory are per_item by default and collecting there would silently disable on_error, retry, and the error port for the most ordinary nodes in the engine.
…orkflows `execution: per_item` turns sub_workflow into the multiplier: one full child run per input item, each seeded with just that item and resolving `workflow_id` against it, bounded by `concurrency`. Default stays `once`, so existing graphs are unchanged. The depth guard is per child run, so a fan-out widens the run without deepening it — N siblings at depth d+1, never d+N.
`execution`, `concurrency`, and `on_item_error` select the execution strategy, so a bad value cannot be caught at run time without silently changing behaviour. Notably a fan-out knob on a node that runs once is rejected rather than ignored — otherwise an author asks for parallelism and gets none with no signal at all.
The catalog is what an authoring agent reads to discover config, so a feature absent from it is unreachable. Described once and appended to the five mapping kinds rather than copied into contracts that would drift.
…-isolated Covers the properties that make the feature usable rather than merely present: real overlap (a probe records peak in-flight, so a regression to a sequential loop fails loudly), input-order results, the sequential default, and both item-error policies.
There was a problem hiding this comment.
senamakel has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 8 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (12)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4817cd51b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let (items, _) = | ||
| crate::nodes::map::map_items(ctx.input.len(), opts, move |index| async move { |
There was a problem hiding this comment.
Keep approval failures outside item collection
When a per-item sub-workflow uses fan-out concurrency, map_options defaults to collect, so an error returned by run_child because a child paused at a requires_approval gate is converted into a normal error item. The parent then reports success and schedules downstream nodes, bypassing the existing fail-closed behavior that deliberately halts the parent when cross-boundary approval resume is unsupported. Lifecycle errors for pending approvals must bypass item collection and remain node failures.
Useful? React with 👍 / 👎.
What
Lets a single node multiply an array of input into N concurrent units of work — array in, array out. Where the existing graph fan-out fixes its width when the graph is authored (you hand-write N sibling nodes), this width is data-driven.
execution: per_itemalready existed onagent/tool_call/http_request/memory, but every implementation was a sequentialfor … .awaitloop. This adds the concurrency dial and applies it uniformly, plus a newper_itemmode onsub_workflow.executiononce|per_itemconcurrency"all"1(default) sequential,nbounded,0/"all"unbounded. Clamped to 64.on_item_errorcollect|fail_fast|skipResults always come back in input order with
paired_itemset, so a fan-out never reorders data.Design notes
concurrencydefaults to 1, so nothing existing changes. Fan-out is strictly opt-in; an e2e test pins thatexecution: per_itemalone stays sequential, so a future regression that makes parallelism the default fails loudly.on_item_error's default follows the execution shape —collectwhen fanning out,fail_fastwhen sequential. This is the one subtle decision in the PR. Collecting unconditionally looked reasonable, buttool_call,http_request, andmemoryareper_itemby default, so it would have silently disabledon_error,retry, and theerrorport for the most ordinary nodes in the engine — a graph that never asked for a fan-out would quietly stop failing. It was caught by the existingon_errorsuite going red. Opting into concurrency is opting into batch semantics; an expliciton_item_erroroverrides either way.A fan-out widens a run without deepening it.
sub_workflow'sMAX_SUB_WORKFLOW_DEPTHguard is per child run, so N siblings sit at depth d+1, never d+N.No new dependency.
futures-utilandtokiowere already deps, so the bounded concurrency isbuffer_unorderedwith index-carrying futures re-slotted into input order.tinyagents::graph::parallel::map_reducewas the alternative but would have draggedTinyAgentsErrorconversion across the seam for no gain.Validation rejects a no-op knob.
concurrencyon a node that runs once is an error, not a silent ignore — otherwise an author asks for parallelism and gets none with no signal at all.Surface
src/nodes/map.rs— the one new primitive (map_options,map_items).src/nodes/integration/{agent,tool_call,http_request,memory}.rs— the four sequential loops now call it.src/nodes/integration/sub_workflow.rs— newper_itemmode; body extracted torun_child.src/validate.rs,src/catalog.rs— author-time rejection and machine-readable contracts (the catalog is what an authoring agent reads, so a feature absent from it is unreachable).Validation
cargo test— 449 lib tests + all integration suites greencargo clippy --all-targets --all-features -- -D warnings— cleancargo fmt --check— cleantests/per_item_fanout_e2e.rscovers the properties that make the feature usable rather than merely present: a probe records peak in-flight concurrency (so a regression to a sequential loop fails), input-order results, the sequential default, and both item-error policies.Per-item observability
RunObservergainson_item_start/on_item_finish, both defaulting to no-ops so existing observers are untouched.A fanned-out node is one step running N units of work, so the step callbacks fire once for the whole node and a host can only render it as one long step. These report the individual items, keyed on the input index so a host can match each to the output item that lands in that slot, and carrying the batch size so it need keep no per-node map of its own.
The calls live inside the item future rather than where the stream is built:
buffer_unorderedonly polls up toconcurrencyat a time, so announcing eagerly would show every worker as live the moment the batch began. A test pins that started-but-unfinished never exceeds the bound.Threading the observer to the nodes meant adding a field to
NodeContext(~50 construction sites, mechanical — executor unit tests passNoopObserver).