cj-zhukov commented on PR #25299:
URL: https://github.com/apache/datafusion/pull/25299#issuecomment-6385648280
> I had claude do a review of this PR and it has this to say (among other
things):
>
> Before polishing this, please reply to @comphead explaining why
make_array/lit don't fit (positional pairing vs. the same value on every row),
and address row-order determinism. As written, any multi-partition input —
including read_batches(..).filter(..) on a multi-threaded runtime — pairs rows
with the wrong values.
> ## 2 Blocker: rows get paired with the wrong values
>
> `AddColumnExec::execute` (`datafusion/core/src/dataframe/mod.rs:3119`)
silently wraps any multi-partition input in a `CoalescePartitionsExec`:
>
> ```rust
> // A single ArrayRef is one global vector, so the input must be one stream.
> let input = if self.input.output_partitioning().partition_count() == 1 {
> Arc::clone(&self.input)
> } else {
> Arc::new(CoalescePartitionsExec::new(Arc::clone(&self.input)))
> };
> ```
>
> That has two consequences:
>
> * **Row order is effectively random.** `CoalescePartitionsExec` merges
partitions in whatever order batches arrive, so row _i_ of the output is not
row _i_ of the input.
>
> * **Most inputs have multiple partitions.** The inner plan is built
with `state.create_physical_plan`, and the optimizer routinely splits even a
single in-memory source into many partitions.
>
>
> ### Evidence
>
> I checked out the PR locally and ran probe tests. Each one:
>
> * built 32,000 rows with `id` = 0..32000,
>
> * added `tag` = 0..32000 via `array_col`,
>
> * counted rows where `id != tag`.
>
>
> Input Runtime Rows mismatched (of 32,000)
> `MemTable` with 8 partitions single-threaded 19,000, in 10
of 10 runs
> `ctx.read_batches(...).filter(id >= 0)` single-threaded 0
> `ctx.read_batches(...).filter(id >= 0)` multi-threaded (8 workers)
20,000 to 32,000, in 10 of 10 runs
>
> EXPLAIN for the `read_batches` case shows the optimizer split the source
into 8 partitions underneath `AddColumnExec`:
>
> ```
> AddColumnExec: tag
> FilterExec: id@0 >= 0
> DataSourceExec: partitions=8, partition_sizes=[4, 4, 4, 4, 4, 4, 4, 4]
> ```
>
> The hidden coalesce doesn't appear in EXPLAIN at all, because it's created
inside `execute()` rather than in the plan.
> ### Why the PR's tests pass
>
> They use 3-row `dataframe!` inputs on the default single-threaded
`#[tokio::test]` runtime, which is the one setup where this doesn't show up.
> ### What a fix needs
>
> Declaring `required_input_distribution = SinglePartition` isn't enough on
its own. It would make the merge visible in the plan, but the merge still
wouldn't preserve order. Pairing by position needs a defined row order, and
DataFusion plans don't guarantee one.
> ## 3 The marker UDF is fragile
>
> array_col is an Expr that only works in one exact position: the top level
of with_column, optionally wrapped in an alias. Anywhere else it passes
planning and then fails at execution. I confirmed both of these:
>
> * df.select([array_col(a).alias("x")]) plans fine, then fails at
collect with "array_col() can only be used with DataFrame::with_column".
>
> * with_column("x", array_col(a) + lit(1)) behaves the same way.
>
>
> The type system says "this is an expression", but the real rule is "this
is a with_column argument". That's the kind of hidden assumption that turns
into a stream of bug reports. If the feature survives, a separately named
method states the contract honestly. That was the PR's original shape, and the
issue's with_array_columns sketch matches it. Hiding the method made the API
look smaller while making it trickier to use.
> ## 4 Inconsistent behaviour inside with_column
>
> * Replacing a column: with_column is documented as "Add or replace a
column". with_column("id", lit(5)) replaces id, but with_column("id",
array_col(..)) fails with Column 'id' already exists (mod.rs:2953). The same
method now behaves differently depending on the argument.
>
> * Nullability: the new field is always marked nullable (mod.rs:2963),
whatever the array actually contains.
>
> * Multiple columns: each array_col call adds another table-scan
wrapper and another AddColumnExec, each planned separately. The PR's own type
test chains 13 of them.
>
>
> ## 5 Structure and conciseness
>
> * Wrong file: about 390 lines of UDF, TableProvider and ExecutionPlan
code went into dataframe/mod.rs, which grows from 3,055 to 3,320 lines. This is
execution-layer code. If it's kept, it belongs in its own module (for example
dataframe/add_column.rs), next to the existing ViewTable pattern
(datafusion/catalog/src/view.rs) that wraps a logical plan as a table.
>
> * Unrelated import churn: about 60 lines of the diff reorder imports
that have nothing to do with the change, which hides the real diff. It should
be reverted.
>
> * Duplicated example: the same doc example appears three times — on
with_column, on the private with_array_column, and on array_col. A private
helper doesn't need a full example, and array_col's copy is identical to the
one on with_array_column.
>
> * Hidden coalesce: related to item 2, execute() decides partitioning
itself instead of declaring what it needs. That's an anti-pattern whatever
happens to the design.
>
> * Pass-through code: replace_children / with_new_children add bulk
without doing anything useful.
>
>
> Realistic options:
>
> 1. **Restrict to inputs with a guaranteed order.** For example, build
the inner plan with `target_partitions = 1` and document the limitation clearly.
>
> 2. **Use the relational approach.** Join on a key, or on
`row_number()` over an explicit `ORDER BY`.
@Omega359
Thanks for reviewing the PR and pointing out these issues! I’ll investigate
the row-order determinism and multi-partition handling and work on addressing
them.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]