Skip to content

fix: support struct-typed scalar subquery results - #5889

Open
LinSimon-901101 wants to merge 4 commits into
apache:mainfrom
LinSimon-901101:fix/5834-struct-scalar-subquery
Open

fix: support struct-typed scalar subquery results#5889
LinSimon-901101 wants to merge 4 commits into
apache:mainfrom
LinSimon-901101:fix/5834-struct-scalar-subquery

Conversation

@LinSimon-901101

@LinSimon-901101 LinSimon-901101 commented Sep 12, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Closes #5834.

Rationale for this change

Spark's merged subqueries produce struct-typed scalar results that currently force the consuming projection to fall back to Spark. Spark 4.2's MergeSubplans broadens this to queries without explicit subqueries, potentially taking Union and downstream aggregates off Comet as well.

What changes are included in this PR?

  • Support non-empty struct scalar-subquery results with distinct field names and supported scalar or nested-struct fields.
  • Transfer materialized results through Arrow IPC and cache owned native values within the expression's execution context.
  • Preserve NULL-struct semantics, field order and planned metadata, while leaving existing non-struct paths unchanged.
  • Add SQL, optimizer-plan and Rust regression tests, and document the supported scope.

Duplicate-name structs and unsupported field types retain fallback. This PR does not claim to resolve #4949 or the separate native shuffle/Parquet issues in #5605 and #5783. Existing malformed-UTF-8 limitations still apply.

How are these changes tested?

Local validation on macOS arm64:

  • Before the upstream merge: focused regressions passed on Spark 3.4.3, 3.5.9, 4.0.4 and 4.1.3.
  • After the upstream merge, at 918202206: native build succeeded; Spark 4.2.0 focused tests passed (3 tests), and Rust scalar-subquery tests passed (5 tests). Scalastyle and Spotless also passed.
  • Red/green comparison on Spark 4.1.3: the same two regressions failed against the original base because of unsupported structs/projection fallback, then passed with the implementation.

The tests compare Spark results and assert native operator coverage, including the consuming projection and the Spark 4.2 Union/aggregate reproducer. The latter uses the existing test setup with Spark-to-columnar input conversion enabled.

Clippy and Apache RAT passed locally at 918202206. Scalafix also passed both the syntactic check and the semantic CHECK using Spark 4.0 / JDK 21.
These local checks do not cover the full CI matrix. No benchmarks were run, and no measured performance improvement is claimed.

@github-actions github-actions Bot added bug Something isn't working area:expressions Expression evaluation area:ffi Arrow FFI / JNI boundary labels Sep 12, 2026
@LinSimon-901101

Copy link
Copy Markdown
Author

Local validation at 918202206 on macOS arm64:

  • Spark 4.2.0 / JDK 21: 3 focused tests passed, 0 failed.
  • Rust scalar-subquery tests: 5 passed, 0 failed.
  • Native build, Scalastyle and Spotless passed.

The Spark 4.1.3 logs show the pre-merge red/green comparison: the same two regressions failed against the original base due to unsupported struct results/projection fallback, then passed with the fix. The baseline failures are expected.

Logs are attached below.
spark-4.2-focused.txt
rust-struct-subquery.txt
spark-4.1-baseline-expected failures.txt
spark-4.1-fixed-before upstream merge.txt

@LinSimon-901101
LinSimon-901101 marked this pull request as ready for review September 12, 2026 20:08

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed 91820220 against de1eb4f8. No verified P1/P2 findings.

Correctness

Previously, the struct returned by Spark's merged scalar subqueries caused the consuming projection to fall back. The recursive support check now admits non-empty structs with distinct field names and supported leaves. Array/map/interval fields, duplicate names, non-default collations and unsupported decimal scales retain fallback.

The one-row, one-column IPC envelope preserves the distinction between a null struct and a present struct containing null fields. Native decoding validates shape, field order, names, nullability and leaf types before restoring planned metadata. Timestamp fields retain UTC microseconds, while timestamp-without-time-zone fields remain unzoned. Spark still enforces scalar cardinality before native evaluation. Each task iterator builds a fresh native plan, so the owned cache stays within that execution; cache contents do not affect expression equality or hashing.

The SQL fixture covers supported leaves, nested nulls, empty results, separate subqueries and recursive fallback. The optimizer tests verify actual struct subqueries and a native consuming projection, including the Spark 4.2 union/aggregate case. I inspected the author's attached logs: they report three Spark 4.2 tests and five Rust tests passing at the stated head, plus the pre-merge Spark 4.1 red/green comparison. These are author-provided results, not independent CI. CI remains action_required with zero jobs; the sole successful check is labeling. I ran no local JVM/native tests. Canonical source checks covered the maintained Spark 3.5/4.0 branches; the required 3.4/4.1 branches were unavailable.

Performance

The first evaluation pays for row normalization, Arrow allocation, IPC serialization and decoding. Subsequent evaluations reuse the owned scalar, avoiding repeated IPC work per batch for that expression. Repeated references can still own separate caches, so this does not establish one transfer per logical subquery. Primitive transfer paths remain unchanged apart from the empty-cache lookup. No benchmark was run and no measured speedup is established. Could you add a focused comparison against the previous Spark fallback, varying struct width and input-batch count, to quantify first-transfer cost and how well the cache amortizes it?

Design

Reusing the existing Arrow writer keeps value transfer aligned with the project's supported primitive representations. The Scala support gate and native wire validation serve separate purposes: the former decides fallback before execution, while the latter rejects a malformed or mismatched result. Metadata restoration checks the value shape rather than introducing casts. The change addresses the struct-transfer seam and explicitly retains the existing malformed-UTF-8 limitation and separate scan/shuffle limitations.

Abstraction & complexity

The implementation adds a focused serializer, decoder and execution-local cache without a new plan protocol or global cache. The explicit equality/hash implementations preserve the original three identity fields while excluding mutable state. Recursive normalization and metadata alignment are limited to the supported struct shape; the added tests exercise those responsibilities directly. I found no additional abstraction or simplification that warrants a pre-merge change.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Follow-up at unchanged 91820220 / base de1eb4f8:

[P2] Regenerate q9’s approved plan for the newly native struct-subquery projection. All five exec jobs fail only check simplified (tpcds-v1.4/q9): 3.4, 3.5, 4.0, 4.1, 4.2. The unchanged golden expects Project [COMET: Unsupported data type: StructType(...)]; each actual plan has CometColumnarToRow → CometProject and reports 38 accelerated operators instead of 37. Please regenerate and commit the affected approved plan using the documented golden-file workflow, then rerun the supported-profile plan-stability checks.

These are actual plan-comparison failures at the exact tested H/B merge. The native producer and JVM consumers share artifact 10307852860 and its verified digest. The merged-struct result/operator test and scalar_subquery_struct.sql pass on all five profiles; the five Rust struct tests also pass. This supports an expected-plan omission, without demonstrating a query-result regression. I replayed the captured plan comparison locally; I did not run Spark/Rust tests or benchmarks locally. Other CI jobs remain pending.

@LinSimon-901101

Copy link
Copy Markdown
Author

Thanks for the review; I've regenerated q9's shared golden file to reflect the native CometProject, with no production code or assertion changes.
Both complete TPC-DS plan-stability suites passed locally on all five Spark profiles (3.4–4.2) with golden generation disabled: 129 tests per version, 0 failures.
Spark 3.4 used JDK 17 locally rather than CI's JDK 11, and CI validation of the update remains pending.

Comment on lines +37 to 54
private def supportedStructField(dt: DataType): Boolean = dt match {
case s: StructType =>
s.nonEmpty && s.fieldNames.distinct.length == s.length &&
s.fields.forall(f => supportedStructField(f.dataType))
case BooleanType | ByteType | ShortType | IntegerType | LongType | FloatType | DoubleType |
StringType | BinaryType | DateType | TimestampType | TimestampNTZType | NullType =>
true
case d: DecimalType => d.scale >= 0 && d.scale <= d.precision
case _ => false
}

override def getSupportLevel(expr: ScalarSubquery): SupportLevel = {
val supported = expr.dataType match {
case s: StructType => supportedStructField(s)
case dt => supportedDataType(dt)
}
if (supported) {
Compatible()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Related to #5025, which centralizes these recursive type checks. Could we coordinate this with that helper instead of adding another independent predicate? It already covers duplicate names, string types, and interval restrictions. This path would still need its struct-only and decimal-scale restrictions preserved.

Comment on lines +283 to +285
// Concurrent first evaluations may both initialize the same immutable result.
// Failed evaluations are never cached.
let _ = self.struct_value.set(value.clone());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can the same native Subquery instance be evaluated concurrently here? Separate Spark tasks have separate instances. Understanding the sharing would help decide whether lazy initialization is needed.

Comment on lines +160 to +162
if let Some(value) = self.struct_value.get() {
return Ok(ColumnarValue::Scalar(value.clone()));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

By native physical planning, Spark has materialized and registered the subquery result. Could we resolve it into an immutable scalar there? What requires deferring initialization until evaluate?

if reader.next().transpose()?.is_some() {
return internal_err!("Scalar subquery IPC result contains more than one batch");
}
let value = align_struct_metadata(batch.column(0), data_type)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Rust test covers restoring metadata. Could we also exercise a struct with Parquet field IDs through the JVM serializer, showing the schema mismatch that occurs without this alignment?


/// The JVM bridge emits one row with one struct column. Validate the wire shape and type before
/// creating the scalar; Arrow IPC validation also keeps malformed strings out of native arrays.
fn decode_struct_result(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The JVM writer always emits one row, one column, and one batch. Can these shape checks fail through a supported production path? If not, could we simplify them?

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think GetStructField over the new scalar struct only works when the consuming batch has exactly one row.

Subquery::evaluate returns ColumnarValue::Scalar(ScalarValue::Struct(..)) holding a one-row StructArray. The scalar arm of GetStructField::evaluate handles that by returning ColumnarValue::Array(child_with_parent_nulls(&struct_array, self.ordinal)?), and child_with_parent_nulls hands back the child of that one-row struct, so the result is a length-1 Array rather than a scalar. DataFusion then runs it through ColumnarValue::into_array_of_size(batch.num_rows()), which rejects an Array whose length is not the batch row count with Array length 1 does not match expected length N.

So SELECT c1, (SELECT max(x) FROM t2), (SELECT min(x) FROM t2) FROM t1 should fail as soon as t1 produces a batch with more than one row, and that is the main shape MergeScalarSubqueries creates. Would it make sense for the scalar arm of GetStructField::evaluate to return ColumnarValue::Scalar(ScalarValue::try_from_array(&child, 0)?) so the projection broadcasts it instead?

I think the reason the tests do not catch this is that all of them land on one-row batches. CometTestBase runs local[5], so the 4-row INSERT ... VALUES in scalar_subquery_struct.sql becomes four one-row Parquet files and withParquetTable((0 until 5)...) becomes five one-row files. The Spark 4.2 test's projection sits directly over one-row aggregates, and q9 filters reason down to r_reason_sk = 1. Could you add a fixture where the table is written as a single file so the consuming projection sees a batch with many rows, and check that it fails without the GetStructField change?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:expressions Expression evaluation area:ffi Arrow FFI / JNI boundary bug Something isn't working

Projects

None yet

4 participants