kazantsev-maksim commented on issue #5124:
URL: 
https://github.com/apache/datafusion-comet/issues/5124#issuecomment-5463380477

   **Implementation plan for native CollectMetricsExec (df.observe)**
   
   Picking this up. Below is the proposed plan and the constraints I expect to 
hit, so we can align before implementation starts.
   
   **Goal**
   
   Add a native pass-through operator that forwards batches unchanged 
(preserving stage fusion - the primary win called out in the issue) while 
computing the observed aggregate expressions, and report the results back so 
QueryExecutionListener / Observation events fire with the same values as 
vanilla Spark.
   
   **Proposed design**
   
   * **Native operator (Rust).** A pass-through ExecutionPlan that:
     * forwards every input RecordBatch downstream unchanged;
     * feeds each batch into DataFusion Accumulators built from the observed 
aggregate expressions;
     * on stream exhaustion, finalizes the accumulators and evaluates the 
metric expressions to produce the final per-partition metric values.
   * **JVM operator + serde.** CometCollectMetricsExec (serde in 
QueryPlanSerde) that:
     * serializes the aggregate + metric expressions into the native plan;
     * holds a real Spark AggregatingAccumulator, constructed from the original 
CollectMetricsExec.metricExpressions / child.output / conf, registered on the 
driver.
   * **Reporting path.** Publish the native-computed values back to the JVM 
through the existing CometMetricNode plumbing (the same channel used by 
bytes_scanned / output_rows for scan input metrics), then read them in a 
TaskCompletionListener in CometExecRDD.compute(), mirroring 
reportScanInputMetrics / reportSpillMetrics. The listener merges the values 
into the AggregatingAccumulator so driver-side aggregation and Observation work 
unchanged.
   
   **Constraints we expect to hit (need decisions up front)**
   
   1. **Metric discovery is strictly type-based.** 
QueryExecution.observedMetrics → CollectMetricsExec.collect(executedPlan) 
matches only case collector: CollectMetricsExec. Our node is a CometUnaryExec, 
and Spark's CollectMetricsExec is a case class (can't subclass), so once we 
replace the node collect() finds nothing and Observation.get returns 
empty/default.
   
      *Solution:* Keep a thin real CollectMetricsExec node in the executed plan 
as the accumulator "carrier" (populated by our listener), with the native 
pass-through underneath. This works with unmodified Spark; the compute subtree 
still fuses.
   
   2. **Metric value types.** The CometMetricNode channel (set(name, v: Long) → 
SQLMetric) carries only Long. observe allows arbitrary result types (count → 
Long, but sum(double), min(string), structs do not fit).
   
      *Proposal:*
      * Phase 1: support long-typed aggregates only (covers count / null-count 
- the most common data-quality checks), fall back to Spark otherwise.
      * Phase 2: add a separate channel carrying an InternalRow of metric 
values instead of reusing SQLMetric.
   
   3. **Cross-partition / retry merge.** Each partition finalizes 
independently; the merge must stay on the driver via the Spark 
AggregatingAccumulator, not in native code. This keeps us aligned with 
SPARK-58183 (last-attempt semantics) and SPARK-50007 (default values when the 
node is pruned).
   
   4. **Fusion vs. fallback behavior (to verify).** We need to confirm that 
with the chosen approach the child subtree stays native (unsupported/carrier 
node = a boundary) rather than triggering a full fallback of the whole subtree. 
This depends on how CometExecRule handles the node and needs to be validated 
during implementation.
   
   **Scope guardrails**
   
   observe already forbids distinct and non-deterministic aggregates, keeping 
the surface small. Conversion in QueryPlanSerde will be gated on: all metric 
aggregates supported natively and long-typed (phase 1), else fall back.
   
   **Milestones**
   
   1. Proto + QueryPlanSerde serde + gating (long-only, supported aggregates).
   2. Native pass-through operator computing and finalizing aggregates.
   3. Publish path via CometMetricNode + TaskCompletionListener → 
AggregatingAccumulator.
   4. Carrier-node discovery approach wired up; verify child stays native.
   5. End-to-end test (df.observe(count(lit(1)))) asserting observation.get 
matches vanilla Spark, plus multi-partition and pruned-node cases.
   
   **Questions for reviewers**
   
   * Is the carrier-node approach (CollectMetricsExec above the native 
operator) acceptable?
   * Is a long-only phase 1 acceptable to land first, with full-type support 
tracked separately?
   * Any concerns with the TaskCompletionListener → accumulator merge under 
task retries / speculation?
   
   **One honest note for us (not for the comment):** constraints #1 (type-based 
discovery) and #2 (SQLMetric is Long-only) are confirmed from the Spark source 
and the CometMetricNode.scala you shared. Constraint #4 (child stays native 
with the carrier-node approach) I've flagged as "to verify" rather than 
asserting it — we should confirm it against CometExecRule / planner.rs before 
committing to the approach.


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