Skip to content

GH-3211: Implement Variant parquet reader - #3212

Merged
rdblue merged 31 commits into
apache:masterfrom
cashmand:variant_shredding_avro_reader
May 21, 2025
Merged

GH-3211: Implement Variant parquet reader#3212
rdblue merged 31 commits into
apache:masterfrom
cashmand:variant_shredding_avro_reader

Conversation

@cashmand

@cashmandcashmand commented May 7, 2025

Copy link
Copy Markdown
Contributor

Rationale for this change

Provide a reference implementation for reading Variant values from Parquet based on the Variant shredding spec in https://github.com/apache/parquet-format/blob/master/VariantShredding.md.

What changes are included in this PR?

Adds VariantConverter classes to convert shredded Variant values in Parquet into a single metadata/value pair, representing the full value.

The PR adds a few new methods to the existing Variant and VariantBuilder classes to simplify the shredding reader, including a couple that are public to simplify construction of the Avro tests.

The PR also adds support in parquet-avro for using this interface: when a parquet schema element is annotated with the Variant logical type, the Avro reader will produce a record containing only the reconstructed metadata/value pair, rather than the full shredded result.

Are these changes tested?

Added unit tests, mostly based on similar tests in https://github.com/apache/iceberg/blob/main/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantReaders.java.

Are there any user-facing changes?

Connectors can now read Variant values, and the parquet-avro connector will infer a Variant schema if the logical type annotation is present.

Closes#3211

cashmand added 10 commits May 5, 2025 14:30
WIP
Add scalar support
Fix dependencies
Fix
Seems to work for simple scalar example.
Don't modify metadata.
Fixes and more tests
Arrays seem to work.
Object seems to work.
Cleanup
Cleanup
Remove VariantSchema
Cleanup
Swapped value and metadata in Avro, incorporate one Iceberg test
More tests, more recent one fails
Fix after rebase
Remove hack
Fix tests
Set explicit schema
More tests
Fix to handle empty object
More tests, cleanup
More tests
More tests
Cleanup
Handle conflicting empty object and non-object, add test
Fix offset issue and add test
Finish porting remaining Iceberg tests
Cleanup
Cleanup
import Assert.*
Cleanup
Match Iceberg on invalid values, and more cleanup
/**
* @return the constructed Variant value binary, without metadata.
*/
public byte[] valueWithoutMetadata() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if we should make metadata part of this API or if we should pass in a metadata instance that does the lookup to resolve a name. Then metadata would be a separate builder that implements that API.

* Directly append a Variant value. Its keys must already be in the metadata
* dictionary.
*/
void shallowAppendVariant(Binary value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this should use ByteBuffer rather than Binary.

Should this also check that fixed metadata is used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We could check if fixed metadata is used, although it is a bit messy because the nested object/array builders would need to bubble the question up to the root VariantBuilder every time.

I could make it a bit cleaner by changing the builder to store a pointer to the root builder (possibly itself). I think this is better than the current approach of storing the parent in the object/array builders, since those builder actually always want the root builder, not the parent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I switched to storing a rootBuilder field in VariantBuilder instead of a parent in each child, so that I can add this check. I think it's a mild improvement regardless of this check, but let me know what you think: dffa73c. cc @gene-db

@cashmand
cashmand requested a review from rdblueMay 12, 2025 13:38
Comment threadparquet-variant/src/main/java/org/apache/parquet/variant/Variant.java Outdated

VariantArrayBuilder(VariantBuilder parent) {
this.parent = parent;
VariantArrayBuilder(VariantBuilder rootBuilder) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As long as we are changing the structure, would it make sense to separate out the metadata?

Right now, we assume that the builder can add keys. But that's a very simple interface with just int addDictionaryKey(String). And we also have to add a boolean for fixed metadata and a new setFixedMetadata method. It seems like it may be easier to change the builder so that it accepts some MetadataBuilder:

interfaceMetadata {
intresolve(Stringkey); // maybe a better name?ByteBuffergetEncodedBuffer();
}

Then you could pass fixed metadata using a simple wrapper class, EncodedMetadata that throws an exception if you're resolving a string that is missing. And when building metadata from scratch, resolve would assign the new ID and produce encoded metadata when getEncodedBuffer is called.

I think that would be simpler than having so much metadata-related state in the builder itself.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Good idea, I refactored along the lines you suggested, with VariantBuilder using MetadataBuilder by default, but ImmutableMetadata when created from the shredding reader.

*/
public Variant build() {
if (fixedMetadata) {
throw new IllegalStateException("Cannot reconstruct metadata when using fixed metadata");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you passed in metadata, then this would not be needed. And it would separate the concerns of building metadata and building values.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done.

.type(Schema.create(Schema.Type.BYTES))
.noDefault();
builder.name("value").type().optional().type(Schema.create(Schema.Type.BYTES));
return of(builder.endRecord());

@rdbluerdblueMay 12, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should also add a variant logical type name to the record. Probably not blocking though.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah, I think that adding the logical type here would allow the caller to convert this record into a Variant representation using Avro logical type converters. Not a high priority, but it would be a good addition.

Comment threadparquet-avro/src/main/java/org/apache/parquet/avro/AvroConverters.java Outdated
Comment threadparquet-avro/src/main/java/org/apache/parquet/avro/AvroConverters.java Outdated
* Converter for shredded Variant values. Connectors should implement the addVariant method, similar to
* the add* methods on PrimitiveConverter.
*/
public abstract class VariantColumnConverter extends VariantElementConverter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It appears that this is the top-level Variant. I think that should be called out more clearly than just "VariantColumn". I see why that makes sense, but "column" is overloaded so we should have a name that signals to the reader a little more clearly. Using VariantConverter would make sense to me if it weren't already used for the interface. Maybe VariantValueConverter could be used for the interface instead since those are dealing specifically with values and this is both metadata and value?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I already use VariantValueConverter for the converter for the value field :(. Maybe VariantConverterInterface? Naming is the hardest thing.

}
if (topLevelMetadataIdx < 0) {
throw new IllegalArgumentException("Metadata missing from schema");
}

@rdbluerdblueMay 12, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like the loop and the check above this line could be replaced with:

this.topLevelMetadataIdx = variantSchema.getFieldIndex("metadata");

That will throw an exception if metadata isn't found.

That said, I'm wondering if this should convert all fields in order and just have handling for value, typed_value, and metadata fields. (And ignore or fail on any other names)

currentValue = null;
}

Binary getValue() {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I'll try to replace this with a setBinaryValue() method to push the value to the parent, to be more consistent with how setMetadata() is being called.

*/
public class ImmutableMetadata implements Metadata {
private HashMap<String, Integer> dictionary = null;
private ByteBuffer metadataBuffer;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These can (and should) both be final.

public int getOrInsert(String key) {
Integer result = dictionary.get(key);
if (result == null) {
throw new IllegalArgumentException("Key does not exist in metadata");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's usually a good idea to add any context that is helpful, like which key is missing: "Key does not exist in metadata: " + key

}

@Override
public void addBinary(Binary value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would probably make sense to refactor these methods into the base BinaryConverter and add a convert method that does the implementation-specific part:

abstractclassBinaryConverter ... {
protectedabstractvoidconvert(Binaryvalue);
publicvoidaddBinary(Binaryvalue) {
convert(value);
}
publicvoidaddValueFromDictionary(intdictionaryId) {
convert(dict[dictionaryId]);
}
}
classVariantMetadataConverterextendsBinaryConverter {
protectedvoidconvert(Binaryvalue) {
getParent().setMetadata(value);
}
}

Also, why use Binary instead of ByteBuffer for the internal API?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Sounds good. There was no reason to use Binary, it was just what the addBinary method in PrimitiveConverter provides. I can convert it to ByteBuffer immediately.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Oh, actually, I think there was a reason to use Binary. When I set the metadata, I check for reference equality with the previous value, so that if the metadata was the same dictionary-encoded value in every row, we don't need to rebuild it. If I immediately convert it to a ByteBuffer with toByteBuffer, I don't think it would compare equal to the previous ByteBuffer.


@Override
public VariantBuilder getBuilder() {
return null;

@rdbluerdblueMay 14, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this need to be a VariantConverter? I don't think anything here is specific to Variant. I'm not sure that the children need to be either, or at least that the metadata converter doesn't need to be. That could be passed a Consumer<ByteBuffer> instead of a parent and pass the buffer to it.

That avoids needing to pass null here, which violates the contract of VariantConverter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Good point, it doesn't.


@Override
public void addValueFromDictionary(int dictionaryId) {
((VariantColumnConverter) getParent()).setMetadata(dict[dictionaryId]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As I mentioned above, changing this so that setMetadata is passed as a Consumer<ByteBuffer>:

abstractstaticclassBinaryConverterextendsPrimitiveConverter {
Binary[] dict;
BinaryConverter() {
dict = null;
}
protectedabstractvoidhandleBinary(Binaryvalue);
@OverridepublicbooleanhasDictionarySupport() {
returntrue;
}
@OverridepublicvoidsetDictionary(Dictionarydictionary) {
dict = newBinary[dictionary.getMaxId() + 1];
for (inti = 0; i <= dictionary.getMaxId(); i++) {
dict[i] = dictionary.decodeToBinary(i);
}
}
@OverridepublicvoidaddBinary(Binaryvalue) {
handleBinary(value);
}
@OverridepublicvoidaddValueFromDictionary(intdictionaryId) {
handleBinary(dict[dictionaryId]);
}
}
staticclassVariantMetadataConverterextendsBinaryConverter {
privatefinalConsumer<ByteBuffer> parent;
publicVariantMetadataConverter(Consumer<ByteBuffer> parent) {
super();
this.parent = parent;
}
@OverrideprotectedvoidhandleBinary(Binaryvalue) {
parent.accept(value.toByteBuffer());
}
}

Then that converter is instantiated like this:

converters[topLevelMetadataIdx] = newVariantConverters.VariantMetadataConverter(this::setMetadata);

}
} else if (fieldName.equals("typed_value")) {
this.typedValueIdx = i;
}

@rdbluerdblueMay 14, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If there is no else case, then I think this is going to fail at read time when Parquet tries to call methods on a null converter. To ignore a field we would need to add a noop converter. For now, I'd recommend just failing with a good exception message, like throw new UnsupportedOperationException("Cannot read variant with unexpected field: " + fieldName);

You probably won't want to throw the exception for "metadata" though, since this is currently reused for groups with metadata.

@cashmand
cashmand requested a review from rdblueMay 19, 2025 16:11
*/
static HashMap<String, Integer> getMetadataMap(ByteBuffer metadata) {
int pos = metadata.position();
checkIndex(pos, metadata.limit());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Doesn't ByteBuffer guarantee that its position and limit are valid?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I believe it's possible for position to equal limit (i.e. no byte can be read).

@rdblue

Copy link
Copy Markdown
Contributor

This looks good to me. I'll merge when tests are passing.

@rdblue
rdblue merged commit 1f1e07b into apache:masterMay 21, 2025
@rdblue

Copy link
Copy Markdown
Contributor

Thanks for fixing the tests, @cashmand! I merged this.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Variant reader

2 participants

@cashmand@rdblue