dongjoon-hyun commented on PR #57952:
URL: https://github.com/apache/spark/pull/57952#issuecomment-5329944181

   ### Review findings
   
   Overall this looks well-designed and well-tested. I found a few issues worth 
addressing before merge — two functional, two error-handling, and some 
duplication/efficiency notes.
   
   #### Functional
   
   1. **Profiler breaks incremental aggregators** — `python/pyspark/sql/udf.py` 
(~L605): the profiler exclusion list in `UserDefinedFunction.__call__` was not 
extended with `SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF`. With 
`spark.python.profile=true`, `self.func` (the `Aggregator` object) is replaced 
by a plain wrapper function before pickling, so the worker fails with 
`AttributeError: 'function' object has no attribute 'bufferSchema'`. With 
`spark.python.profile.memory=true` it fails even earlier on the driver at 
`inspect.getsourcelines(f.__code__)`, since an `Aggregator` instance has no 
`__code__`.
   
   2. **`NormalizePlan.normalizeExprIds` not extended** — 
`sql/catalyst/.../plans/NormalizePlan.scala` (~L112): `resultId` is normalized 
for `PythonUDF`/`PythonUDAF` but not for the new `PythonAggregate`. Any 
`comparePlans`-based test on plans containing a `PythonAggregate` will fail 
spuriously, and `HybridAnalyzer`'s dual-run comparison would report a mismatch 
for semantically identical plans.
   
   #### Error handling
   
   3. **Error message asserts a restriction the implementation doesn't have** — 
`error-conditions.json` (`INVALID_PYTHON_UDF_PLACEMENT`): "Each such function 
must be the only aggregate expression in its aggregation" contradicts the 
supported (and tested, `test_multiple_incremental_aggregators`) case of 
multiple incremental aggregators in one aggregation. Only mixing with *other 
kinds* of aggregate functions is invalid.
   
   4. **Bare `require` instead of a classed error** — 
`UserDefinedPythonFunction.builder` (~L113): `require(bufferType != null, ...)` 
+ unchecked `asInstanceOf[StructType]`. A Connect proto with `eval_type=256` 
and a missing (or non-struct) `buffer_type` surfaces as a raw 
`IllegalArgumentException`/`ClassCastException` (INTERNAL_ERROR) rather than 
following the planner's `InvalidPlanInput` convention. Also reachable 
classically via `UserDefinedFunction(f, evalType=256)` with no `bufferSchema`.
   
   5. **Mixed pandas + incremental diagnostic drops names** — 
`SparkStrategies.scala` (~L825): when both a grouped-agg pandas UDAF and an 
incremental aggregator are mixed with other aggregates, the new fallthrough 
names only the `PythonAggregate` functions; the co-offending pandas UDAF names 
(which the replaced code listed) are omitted. This mixed case has no test.
   
   6. *(Minor, pre-existing pattern)* **Connect `is_distinct` silently 
ignored** — `SparkConnectPlanner.scala` (~L2204): `agg.toAggregateExpression()` 
hardcodes `isDistinct = false`, so a client sending `is_distinct=true` silently 
gets non-distinct results — while this PR's own `FunctionResolution` change 
explicitly rejects DISTINCT on the SQL path ("reject rather than silently drop 
the clause"). Same hole exists for `PythonUDAF`, but this PR is where the 
reject-don't-drop rule was introduced.
   
   #### Efficiency / duplication
   
   7. **Unbounded map-side combine** — `worker.py` PARTIAL handler (~L2294): 
all per-group buffers are held in one dict for the whole partition and emitted 
as a *single* `RecordBatch` (no `maxRecordsPerBatch` chunking, no cap/flush). 
High-cardinality keys — exactly where partial aggregation degenerates — will 
OOM the Python worker where JVM hash aggregation would spill. Since the FINAL 
stage re-merges duplicate keys authoritatively, capping the map and flushing 
early (and emitting the end-of-partition output in bounded chunks) is safe and 
cheap. I saw spill is listed as a follow-up, but a simple size cap + chunked 
emission seems worth doing in this PR.
   
   8. **`doExecute` duplication has already drifted** — 
`PythonIncrementalAggregateExecBase.doExecute` is a near-verbatim copy of 
`ArrowAggregatePythonExec.doExecute`, and the copy hard-codes `lockFree = 
false` (~L162) where the original picks it from 
`PYTHON_UDF_PIPELINED_EXECUTION` — so the FINAL stage silently misses the 
pipelined-queue optimization. Extracting the shared grouped-input/queue/join 
scaffolding (the `argMetas` dedup loop alone now exists twice within this PR) 
would prevent further drift.
   
   9. **Redundant Arrow→Python conversions** — `worker.py` (~L2299): the same 
column is `to_pylist()`-ed once per referencing UDF plus again for grouping 
keys (N aggregators sharing a column ⇒ N+1 conversions per batch). One 
`{offset: pylist}` memo per batch would do.
   
   10. **Duplicated Py4J constructor call** — `udf.py` `_create_judf` (~L535): 
the 8-line constructor invocation is duplicated in an if/else differing only in 
the trailing `bufferType`, with a JVM auxiliary constructor existing solely for 
the arity. Py4J maps `None` → `null` and the Scala param already defaults to 
`null`, so a single call passing `None` would remove both the else branch and 
the auxiliary constructor.
   


-- 
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]

Reply via email to