Conversation
|
this PR is supposed to be more generic, not only Parquet scan, but if UDF or any operator produces a schema with a struct having duplicated column we gonna fallback. Other PRs likely to be built on top of this? WDYT? @andygrove @dwsmith1983 @ErikBPF I expect the change should be straightforward as we know about the schema attached to the plan nodes on the planning side. |
sunchao
left a comment
There was a problem hiding this comment.
Correctness
This change extends duplicate-struct fallback to operator conversion and the Arrow cache serializer. Previously the shared type checker protected scans, shuffle, and row conversion, but an operator or cache could still expose a struct with repeated child names to Java Arrow. The new operator check covers both outputs and data-producing inputs, and the cache check consistently selects Spark's default cache format for unsupported structs.
Maintained Spark 3.5 and 4.0 resolve Parquet field IDs among siblings and raise when a requested ID matches multiple physical fields. That rule also applies at the file-schema root. The new scan guard catches nested duplicates but misses duplicate IDs on top-level fields, because the inherited schema entry point passes only each field's data type. This leaves a declared-schema case of the same equal-schema/no-predicate reader bypass unguarded. The first inline P2 describes the missing root check and regression case.
The second P2 is the missing CI registration for DataTypeSupportSuite. Both preflight runs fail on that omission, and the Spark runtime jobs are skipped. The new reader fixture usefully covers metadata-free files with field-ID matching enabled and disabled, while the cache test checks the selected batch format and readback. Those runtime results are author-reported, not independently established here.
Validation and scope
Reviewed head 204b6574 against base 74725611. The seven authored file changes are identical in merge 3dc05091, which also includes the newer base shuffle commit. Local python3 -B dev/ci/check-suites.py reproduces the preflight failure. An isolated compile of the exact shared type checker and extracted scan checker confirms that top-level duplicate IDs are accepted while nested duplicate IDs are rejected. That probe uses configuration/type-shim stubs and cached Spark type classes. Its later flag-disabled control stops on a missing Kryo class, so this is component evidence, not a passing Spark/JNI suite.
The maintained Spark 3.4 and 4.1 branches were unavailable for semantic comparison. Physical-only duplicate names hidden by a declared schema remain outside this PR's stated scope.
Performance
The added checks run during planning and cache-format selection rather than per row. Falling back prevents constructing Arrow batches whose struct children cannot be represented faithfully. The operator check short-circuits after the first duplicate and preserves ordinary repeated top-level output names.
The scan check recursively walks a type and then the existing support checker recursively visits it again, so deeply nested schemas repeat some work. Moving the ID validation to a single schema-level pass would also address the correctness gap. I have no benchmark evidence of a material planning regression, and no execution-speed claim follows from this review.
Design
A common conversion gate is a sensible place to enforce the Arrow boundary invariant. Inspecting child outputs matters for operators that consume a duplicate-named struct without returning it, and unwrapping WriteFilesExec follows the existing input handling. Using the serializer's support predicate for both cache creation and reading keeps the chosen cache format consistent.
Name duplication and Parquet ID ambiguity need different entry points. Repeated top-level output names can be valid positional columns, but duplicate field IDs at the file root still require the Parquet read check. Keep the current nested-name behavior and apply ID validation to the complete requested scan schema when field-ID matching is enabled.
Abstraction & complexity
The small recursive helper keeps struct, array, and map traversal together and produces a useful path in fallback explanations. It does not require a new framework or configuration surface. Malformed field-ID metadata is excluded from duplicate detection, while the existing Spark-backed schema serialization still validates the ID value.
The main simplification is to validate field IDs once at the schema boundary instead of recursively re-entering the same check through each type. The two inline P2s are the actionable changes from this review.
| // raising, so hand the read back to Spark and let it report the ambiguity. See #5801. | ||
| lazy val duplicateFieldIds = | ||
| if (CometParquetUtils.readFieldId(SQLConf.get)) { | ||
| DataTypeSupport.findDuplicateStructFieldIds(dt, name) |
There was a problem hiding this comment.
Correctness
[P2] Check duplicate field IDs on the complete requested schema
DataTypeSupport.isSchemaSupported calls this method with each field's dataType, so StructType(x: Long id=1, y: Long id=1) reaches this helper twice as LongType and is accepted. I confirmed that with an isolated compile of the exact checker. Wrapping the same fields inside s correctly falls back.
For a metadata-free Parquet file with those two top-level fields and the same requested schema, this leaves the same no-predicate bypass as the new nested fixture: DataFusion 55.1 skips the expression adapter when the schemas are equal. Spark's field-ID ambiguity check applies at the root as well. Please run this check once on the complete requested schema and add the flattened version of the new reader fixture. The exception for repeated top-level output names does not apply to Parquet field IDs.
| * Arrow tell these struct children apart, and can Spark's Parquet reader resolve them one-to-one | ||
| * -- so the answers live in one place and are pinned here. | ||
| */ | ||
| class DataTypeSupportSuite extends AnyFunSuite { |
There was a problem hiding this comment.
Correctness
[P2] Register the new suite in both CI workflow matrices
dev/ci/check-suites.py requires every non-excluded suite to appear in both .github/workflows/pr_build_linux.yml and .github/workflows/pr_build_macos.yml. Neither contains org.apache.comet.DataTypeSupportSuite. The current preflight exits 255 with Suite not found in workflow .github/workflows/pr_build_linux.yml: org.apache.comet.DataTypeSupportSuite, which I also reproduced locally. This stops CI before the runtime test jobs run. Please add the suite to both matrices so the guard succeeds and the new unit tests execute.
Under `spark.sql.parquet.fieldId.read.enabled` Spark resolves each requested field to the one Parquet field carrying its id, and raises FOUND_DUPLICATE_FIELD_IN_FIELD_ID_LOOKUP_MODE when more than one answers. Comet never looked at field ids, and DataFusion 55's opener skips the expression adapter when the file's physical schema compares equal to the logical schema with no pushed predicate, so such a file was read positionally and Comet returned rows where Spark raises. `CometScanTypeChecker` now declines a scan whose requested schema repeats a field id, so the read goes back to Spark and Spark reports the ambiguity. The check is recursive through structs, arrays and maps and is gated on field id matching being on: with it off the fields are told apart by name and the scan stays native. Separately, `ArrowCachedBatchSerializer.supportsType` accepted a struct with duplicate child names, so caching such a relation stored it in Comet's Arrow format, which Java Arrow cannot import back because it keys struct children by name. It is now delegated to Spark's default cache format, alongside the interval types already excluded there, and the shared `hasDuplicateFieldNames` predicate is what both that and the existing `DataTypeSupport` check call. Closes apache#5801.
204b657 to
a406fc7
Compare
andygrove
left a comment
There was a problem hiding this comment.
The force-push changed what this PR is, so flagging that up front. The operator conversion gate is gone, which means the "not only Parquet scan, any operator that produces a duplicate-named struct falls back" part that @comphead described above as the difference from #5786 is no longer in here. What is left is the scan check for duplicate field ids plus the cache serializer check. That is a reasonable PR on its own, but it means the "any operator" half of #5605 is now covered by nothing that is open. Is that coming back here, or should we file it separately so it does not get lost?
One note if it does come back. I ran the earlier head 204b6574 and that gate broke CometArrayExpressionSuite "folded map value with duplicate struct field names falls back (multirow)". The plan still falls back and the answer is still right, but the gate fires before the serde runs, so the recorded reason becomes Native operators do not support v.map value: struct with duplicate field names (x) and the expected Unsupported data type MapType is never recorded. That suite was not in the list you ran locally.
Preflight is red on both runs with Suite not found in workflow .github/workflows/pr_build_linux.yml: org.apache.comet.DataTypeSupportSuite. dev/ci/check-suites.py scans pr_build_macos.yml too, so it needs adding to both. Everything downstream is skipped right now, so there is no runtime coverage of this change in CI yet.
One more small thing that did not fit on a line in the diff. The description says the point of hasDuplicateFieldNames is that the definition of "duplicate name" lives in one place, but CometShuffleExchangeExec.scala:483 and CometShuffleExchangeExec.scala:610 still spell it out by hand as fields.map(f => f.name).distinct.length == fields.length. Could those use the new helper while you are in here?
I ran this locally on the Spark 4.1 profile, JDK 17, macOS aarch64, at a406fc75. Both of your new runtime tests pass, and so does CometExpressionSuite "named_struct with duplicate field names". The root-level field id case in my inline comment below fails.
| // carrying its id, and raises when more than one answers. A requested struct that repeats an | ||
| // id cannot be resolved that way, and the native scan reads it positionally rather than | ||
| // raising, so hand the read back to Spark and let it report the ambiguity. See #5801. | ||
| lazy val duplicateFieldIds = |
There was a problem hiding this comment.
I confirmed @sunchao's point about top-level fields with a real run rather than a compile probe, and it does reproduce on this head.
Same construction as your new test in CometNativeReaderSuite, but with the two id-1 fields at the top level instead of inside s:
message spark_schema {
optional int64 x = 1;
optional int64 y = 1;
}
read back with StructType(Seq(withFieldId("x", 1), withFieldId("y", 1))) and spark.sql.parquet.fieldId.read.enabled=true:
COMET: ROWS=[10,20] (CometNativeScan in the plan)
SPARK: ERROR=Found duplicate field(s) "1": [x, y] in id mapping mode.
That is #5801 exactly, one level up. The reason is structural rather than an oversight in the predicate: DataTypeSupport.isSchemaSupported hands isTypeSupported each field's dataType, so the root struct's fields are never compared against each other and this override cannot see them.
Would you consider overriding isSchemaSupported here instead and running findDuplicateStructFieldIds once over the whole requested schema? That covers the root, and it also drops the repeated traversal, since findDuplicateStructFieldIds already recurses while isTypeSupported re-enters it at every nesting level. Could the root case get a test alongside the nested one?
| fields.map(_.name).distinct.length != fields.length | ||
|
|
||
| /** | ||
| * Describes the first struct nested anywhere in `dt` whose children repeat a Parquet field id, |
There was a problem hiding this comment.
Small thing about this doc comment. ParquetReadSupport.matchIdField raises when the id a requested field asks for matches more than one field in the file, whereas this checks whether two requested fields share an id. Those are different predicates, and they only coincide under the equal-schema condition #5801 describes.
That is enough for the gap you are closing, but the comment reads as though the second follows from the first, and someone will rely on that later. Could it say which one is implemented and why the narrower check is sufficient here?
| val MAP_VALUE = "map value" | ||
|
|
||
| /** Spark's `StructField` metadata key for a Parquet field id. */ | ||
| private val FIELD_ID_METADATA_KEY = "parquet.field.id" |
There was a problem hiding this comment.
Comet already uses ParquetUtils.hasFieldId and ParquetUtils.getFieldId for this in serde/operator/package.scala:48-51 and QueryPlanSerde.scala:700. Any reason not to use ParquetUtils.FIELD_ID_METADATA_KEY and ParquetUtils.hasFieldId here rather than redeclaring the key and hand-rolling the metadata read? It would keep the two from drifting if Spark ever changes it.
Which issue does this PR close?
Closes #5801.
Related to #5605, whose duplicate-name check was missing from the Arrow cache serializer. This does not close #5783; see the note at the end.
Rationale for this change
Duplicate Parquet field ids (#5801). Under
spark.sql.parquet.fieldId.read.enabledSpark resolves each requested field to the one Parquet field carrying its id, and raisesFOUND_DUPLICATE_FIELD_IN_FIELD_ID_LOOKUP_MODEwhen more than one answers. Comet never looked at field ids at all. DataFusion 55's opener skips the expression adapter when the file's physical schema compares equal to the logical schema and no predicate is pushed, so the file in #5801 was read positionally and Comet returned rows where Spark raises.The requested schema carries the ids in each
StructField.metadata, so this is decidable from what the plan already holds. No footer read, no execution-path gate.Duplicate struct field names in the cache serializer. Java Arrow keys a struct vector's children by name, so
struct<a, a>loses a child on the way back across the C data interface (#5605).DataTypeSupportdeclines the shape for the scan type checker, the native shuffle predicate and the two row-conversion sinks, butArrowCachedBatchSerializer.supportsTypeaccepted it, sostored the relation as a
CometCachedBatch, a format the native scan over that cache can never import back.What changes are included in this PR?
DataTypeSupportgainshasDuplicateFieldNames, which the existing trait check and the cache serializer both call so the definition of "duplicate name" lives in one place, andfindDuplicateStructFieldIds, which descends through structs, arrays and maps and describes the first offending struct with its path.CometScanTypeCheckerdeclines a scan whose requested schema repeats a Parquet field id, when field id matching is on. The read goes back to Spark, which raises the ambiguity error. With field id matching off,xandyare told apart by name and the scan stays native.ArrowCachedBatchSerializer.supportsTyperejects duplicate child names, so such a relation is cached in Spark's default format instead, alongside the interval types already excluded there.Nothing in the operator conversion path changes.
How are these changes tested?
DataTypeSupportSuite(new):hasDuplicateFieldNamesincluding that names differing only by case are not duplicates (Java Arrow tellsaandAapart, and Spark's own Parquet reader raises on that shape before Comet is asked);findDuplicateStructFieldIdsat every nesting level (struct child, array element, map key, map value, two levels deep); distinct and absent ids; a non-integral id, which is left for Spark'sParquetUtils.getFieldIdto reject rather than misreported as a duplicate.CometNativeReaderSuite: Duplicate field ids inside a struct are not validated when the file schema equals the requested schema and no predicate is pushed #5801 end to end. A Parquet file written with no key-value metadata and schemas<x id=1, y id=1>, read back with that same schema, which is what makes the opener skip the adapter. Comet now raises Spark'sFound duplicate field(s) "1": [x, y] in id mapping modeand the plan carries noCometNativeScanExec. Withspark.sql.parquet.fieldId.read.enabled=falsethe scan stays native and returns the row, pinning that the gate is scoped to field id matching.CometInMemoryCacheSuite: the duplicate-named struct relation is cached asDefaultCachedBatch, reads back correctly, and produces noCometInMemoryTableScan. This test fails onmain.Existing suites run locally and green on the Spark 4.1 profile:
CometExecRuleSuite,CometScanRuleSuite,CometNativeShuffleSuite,CometInMemoryCacheSuite,CometNativeReaderSuite,CometShuffleSuite,DisableAQECometShuffleSuite,ParquetReadV1Suite,CometFuzzTestSuite.cargo fmt --checkandcargo clippy --all-targets --workspace -- -D warningsare clean (no Rust changes here).CometExpressionSuitehas two pre-existingDatePart/dayofweekfailures in my environment that reproduce unchanged on the base commit and are unrelated to this change.Applying
run-spark-4.1-tests, since this touches the scan rule.Why this does not close #5783
#5783 is the duplicate-name case on the read path: a Parquet file whose struct has two byte-identical child names, read with a declared schema naming one of them.
The duplicate is in the file, not in the plan. Everything a planning rule can reach holds the declared schema:
The file schema is obtainable at plan time —
ParquetFileFormat.inferSchemareturnsstruct<s:struct<dup:bigint,dup:bigint>>, and theCOLUMN_ALREADY_EXISTSyou get fromspark.read.parquet(p)comes later, fromDataSource.resolveRelation's own duplicate check, not from inference. But getting it means Comet reading a footer on the driver, which it does not do today, and with the defaultmergeSchema=falseonly the first part-file would be touched, so the check would not hold for a table whose files disagree the way Spark's per-file clipping does. That is a bigger decision than this fix, and the airtight version belongs in the native reader, which is what #5786 and #5654 are for.