Skip to content

feat(substrait): add a hook for consuming relation inputs - #26603

Closed
alexanderbianchi wants to merge 4 commits into
apache:mainfrom
alexanderbianchi:codex/substrait-concurrent-consumer
Closed

alexanderbianchi wants to merge 4 commits into
apache:mainfrom
alexanderbianchi:codex/substrait-concurrent-consumer

Conversation

@alexanderbianchi

@alexanderbianchi alexanderbianchi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem:

Multi-input nodes need the same scheduling decisions: sequential or concurrent consumption, and how much concurrency to allow. Implementing those choices separately in each node duplicates logic.

Solution:

Route input consumption through one overridable consume_rel_inputs hook. Users can use try_join_all, limit concurrency, or apply their own scheduling policy.

Impact:

One implementation controls input scheduling across all nodes using the hook, without rewriting their conversion logic.

Parallel Consumption

We cannot just try_join_all, because the consumer contains mutable state for CTE's and lambda functions. Blind concurrent consumption would lead to race conditions.

Options

  • Fork (#26896): A smaller change today, but consumption still carries mutable scope state that each fork must isolate. Custom consumers can already implement their own forking inside this hook; the upstream fork API is only needed if we want upstream to provide parallel consumption.
  • Context (#26890): A broader API change that removes mutable traversal state from the consumer. With a valid session and the appropriate explicit scope context, consumption can start at any point without relying on the consumer's previous traversal history.

Testing

Since this PR is effectively a refactor, existing testing cover's all the relations in substrait.

@github-actions github-actions Bot added the substrait Changes to the substrait crate label Oct 22, 2026
@alexanderbianchi
alexanderbianchi force-pushed the codex/substrait-concurrent-consumer branch from 58e10b7 to ccbcea5 Compare October 23, 2026 19:54
@alexanderbianchi alexanderbianchi changed the title feat(substrait): consume independent JOIN and UNION inputs concurrently feat(substrait): add a hook for consuming relation inputs Oct 23, 2026

@geoffreyclaude geoffreyclaude left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty straightforward to me! As commented inline, I'd prefer it this were a bit more general (all multi-children nodes, rather than just join_rel and set_rel, if only for consistency's sake.)

from_substrait_rel(self, rel).await
}

/// Consumes independent inputs of a JOIN or UNION, returning plans in input order.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this to be complete, I think makes sense to extend it to other types of multi-children relations. CrossRel for instance, but probably not only.

Comment on lines +36 to +43
let left: LogicalPlanBuilder = LogicalPlanBuilder::from(
consumer.consume_rel(join.left.as_ref().unwrap()).await?,
);
let right = LogicalPlanBuilder::from(
consumer.consume_rel(join.right.as_ref().unwrap()).await?,
);
let (Some(left), Some(right)) = (join.left.as_deref(), join.right.as_deref()) else {
return substrait_err!("JoinRel requires left and right inputs");
};
let [left, right] = consumer
.consume_rel_inputs(&[left, right])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not feel very clean to have two different APIs for consuming relations. Why not just consuming them concurrently just with futures_util::future::try_join_all?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have this lambda code.

consumer.push_lambda_parameters(...);     // Enter the function's scope.
consumer.consume_expression(body, ...).await?;
consumer.pop_lambda_parameters();         // Leave that scope.

It keeps a stack because lambdas can be nested. The consumer carries a record of “where am I in this query?” Running two branches concurrently on one consumer can mix those records. There's a race condition here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 How can that be, consumer is not mutable. Does that mean there's a Mutex somewhere?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that's the case, any chance to fix that instead so that we can consume concurrently?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this

let nested = context.with_outer_schema(outer_schema).with_lambda_parameters(parameters);
try_join_all(
    inputs.iter().map(|rel| self.consume_rel(rel, context))
).await

That has some context where we can pass in the context explicitly removes state front the consumer but is a broader API change.

@alexanderbianchi alexanderbianchi Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason this PR initially just added a multi input consumer was so we could override it and handle the parallelism complexity in the application code that overrides the consume.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then this is still a problem for the new .consume_rel_inputs() API, people implementing it can decide to do try_join_all() inside and shoot themselves in the foot producing incorrect plans because of mutation races to the lambda parameters or the correlated subqueries.

So whether we ship the .consume_rel_inputs() or do the try_join_all() ourselves in this code here seems like a matter of who is responsible for producing incorrect plans, DataFusion or its consumers, but from an API design standpoint, I don't think neither of the two are acceptable, we should not offer APIs that can silently produce incorrect plans.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, if we want to make the overall structure safer I think we should pursue something like #26890 which I can clean up and push for review. I can close this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds more robust definitely, I'd definitely be more in favor of pursuing that

@alexanderbianchi alexanderbianchi changed the title feat(substrait): add a hook for consuming relation inputs feat(substrait): consume relation inputs using consumer forks Oct 29, 2026
@alexanderbianchi
alexanderbianchi force-pushed the codex/substrait-concurrent-consumer branch from fd1057f to ccbcea5 Compare October 29, 2026 19:15
@alexanderbianchi alexanderbianchi changed the title feat(substrait): consume relation inputs using consumer forks feat(substrait): add a hook for consuming relation inputs Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

substrait Changes to the substrait crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants