-
Notifications
You must be signed in to change notification settings - Fork 55
Fix BigDiffy NPE on parquet schema mismatch in array fields #902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a6e6a16
d621efe
8a08982
a66f4ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,7 +62,9 @@ object ParquetIO { | |
| .checkReaderWriterCompatibility(s1, s2) | ||
| .getType == SchemaCompatibilityType.COMPATIBLE | ||
|
|
||
| if (isReadCompatible(schemaLhs, schemaRhs)) { | ||
| val compatSchema = if (schemaLhs == schemaRhs) { | ||
| schemaLhs | ||
| } else if (isReadCompatible(schemaLhs, schemaRhs)) { | ||
| schemaLhs | ||
| } else if (isReadCompatible(schemaRhs, schemaLhs)) { | ||
| schemaRhs | ||
|
|
@@ -72,8 +74,58 @@ object ParquetIO { | |
| s"$path2: $schemaLhs not compatible with $schemaRhs" | ||
| ) | ||
| } | ||
|
|
||
| makeCollectionFieldsNullable(compatSchema) | ||
| } | ||
|
|
||
| // Parquet can produce null values for non-nullable array/map fields (e.g. when | ||
| // reading with a schema that has more fields than the file, or when the underlying | ||
| // data simply contains nulls). Avro's GenericDatumWriter.getArraySize calls | ||
| // ((Collection) array).size() with no null guard, causing an NPE. This wraps all | ||
| // non-nullable collection fields in a union with null so the coder tolerates it. | ||
| private[ratatool] def makeCollectionFieldsNullable(schema: Schema): Schema = { | ||
| val collectionTypes = Set(Schema.Type.ARRAY, Schema.Type.MAP) | ||
| val hasFieldsToFix = schema.getFields.asScala.exists { field => | ||
| collectionTypes.contains(field.schema().getType) || | ||
| (field.schema().getType == Schema.Type.RECORD) | ||
| } | ||
|
|
||
| if (!hasFieldsToFix) return schema | ||
|
|
||
| val newFields = schema.getFields.asScala.map { field => | ||
| val fieldType = field.schema().getType | ||
| if (collectionTypes.contains(fieldType) && !isNullableSchema(field.schema())) { | ||
| // Original type first so any existing default value stays valid; | ||
| // null second so the coder tolerates nulls from the parquet reader. | ||
| val nullableType = | ||
| Schema.createUnion(field.schema(), Schema.create(Schema.Type.NULL)) | ||
| new Schema.Field( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: these constructors drop field order, aliases, and custom properties, and the new record also loses its aliases and properties. A focused aliased-schema test lost the record alias immediately. Could we use the Avro field copy constructor and explicitly preserve the record metadata? |
||
| field.name(), | ||
| nullableType, | ||
| field.doc(), | ||
| field.defaultVal() | ||
| ) | ||
| } else if (fieldType == Schema.Type.RECORD) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: recursion only handles bare |
||
| val nested = makeCollectionFieldsNullable(field.schema()) | ||
| new Schema.Field(field.name(), nested, field.doc(), field.defaultVal()) | ||
| } else { | ||
| new Schema.Field(field.name(), field.schema(), field.doc(), field.defaultVal()) | ||
| } | ||
| } | ||
|
|
||
| Schema.createRecord( | ||
| schema.getName, | ||
| schema.getDoc, | ||
| schema.getNamespace, | ||
| schema.isError, | ||
| newFields.asJava | ||
| ) | ||
| } | ||
|
|
||
| private def isNullableSchema(schema: Schema): Boolean = | ||
| schema.getType == Schema.Type.UNION && | ||
| schema.getTypes.asScala.exists(_.getType == Schema.Type.NULL) | ||
|
|
||
| private[ratatool] def genericRecordReadConfig(schema: Schema, path: String): Configuration = { | ||
| val job = Job.getInstance(new Configuration()) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocking: this proves the transformed schema can encode a hand-built null record, but it never runs BigDiffy. I extended this fixture through
diffParquetandsc.run(), and it still fails with an NPE while Kryo decodes the array stored in aDelta. Could we make this a full-pipeline regression and fix that downstream serialization path?