SemyonSinchenko commented on code in PR #975: URL: https://github.com/apache/incubator-graphar/pull/975#discussion_r4027380491
########## maven-projects/io-api/src/main/java/org/apache/graphar/io/RecordBatches.java: ########## @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.graphar.io; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Builds {@link RecordBatch} instances from values a producer holds row by row. */ +public final class RecordBatches { + private RecordBatches() {} + + /** + * Transposes row-major values into a columnar batch. + * + * @param schema the batch schema; every row must hold one value per field + * @param rows the rows, each a list of boxed values in schema order + * @return a batch backed by {@link ObjectValueVector} columns + */ + public static RecordBatch ofRows(Schema schema, List<? extends List<?>> rows) { Review Comment: `ofRows` validates only the row width; neither value types nor nullability are checked against the Field definitions. A String in an INT64 column, or a null in a non-nullable column, passes construction silently and only fails later deep inside a format encoder (e.g., `ParquetPhysicalWriter` 's "Unexpected value for ..." / "Required field is null"), far from the site where the bad row was supplied. Since `Field` exposes `type()` and `nullable()`, failing fast here — at minimum rejecting nulls in non-nullable columns — would make producer errors immediately attributable. If deferred validation is intentional, document it on the factory. ########## maven-projects/io-api/src/main/java/org/apache/graphar/io/ObjectValueVector.java: ########## @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.graphar.io; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A {@link ValueVector} over boxed values held in memory. + * + * <p>This is the vector a producer uses when it assembles a batch from Java objects rather than + * decoding one from a file. Values are snapshotted at construction and list values are held as + * unmodifiable copies, so neither the producer nor a consumer can mutate the vector afterwards. + */ +public final class ObjectValueVector implements ValueVector { + private final Field field; + private final List<Object> values; + + public ObjectValueVector(Field field, List<?> values) { + this.field = Objects.requireNonNull(field, "A vector field cannot be null."); + Objects.requireNonNull(values, "Vector values cannot be null."); + List<Object> copy = new ArrayList<>(values.size()); + for (Object value : values) { Review Comment: The snapshot is only one level deep. For nested list values (`ColumnType.listOf(listOf(...)`) is a representable type), the inner lists are neither copied nor made unmodifiable, so the producer's original inner lists remain live references inside the vector and can be mutated after construction. This contradicts the class javadoc's guarantee that neither the producer nor a consumer can mutate the vector afterwards. Either snapshot list values recursively, or narrow the javadoc to state that only the top level of list values is protected. ########## maven-projects/io-api/src/main/java/org/apache/graphar/io/ObjectValueVector.java: ########## @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.graphar.io; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A {@link ValueVector} over boxed values held in memory. + * + * <p>This is the vector a producer uses when it assembles a batch from Java objects rather than + * decoding one from a file. Values are snapshotted at construction and list values are held as + * unmodifiable copies, so neither the producer nor a consumer can mutate the vector afterwards. + */ +public final class ObjectValueVector implements ValueVector { + private final Field field; + private final List<Object> values; + + public ObjectValueVector(Field field, List<?> values) { + this.field = Objects.requireNonNull(field, "A vector field cannot be null."); + Objects.requireNonNull(values, "Vector values cannot be null."); + List<Object> copy = new ArrayList<>(values.size()); + for (Object value : values) { + copy.add(value instanceof List ? List.copyOf((List<?>) value) : value); Review Comment: `List.copyOf` throws a message-less `NullPointerException` when a list value contains null elements, yet the io-api type model explicitly permits such values: `ColumnType.listOf(elementType)` builds a list whose element field is nullable, and `listOfElement` preserves element nullability. A producer assembling a batch for a nullable-element list column that contains a null entry therefore cannot construct this vector at all, and fails with an opaque NPE that points neither at the column nor the offending value. Use a null-tolerant unmodifiable copy (e.g., `Collections.unmodifiableList(new ArrayList<>(...)))` so the vector can represent every value its Field's type allows. ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetPhysicalReader.java: ########## @@ -0,0 +1,426 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.graphar.io.ColumnRef; +import org.apache.graphar.io.ColumnType; +import org.apache.graphar.io.Field; +import org.apache.graphar.io.PhysicalReader; +import org.apache.graphar.io.ReadCapability; +import org.apache.graphar.io.ReadReport; +import org.apache.graphar.io.ReadRequest; +import org.apache.graphar.io.ReadResult; +import org.apache.graphar.io.Schema; +import org.apache.graphar.storage.InputFile; +import org.apache.graphar.storage.Storage; +import org.apache.parquet.ParquetReadOptions; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.SeekableInputStream; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; + +/** A storage-backed reader for GraphAr primitive and LIST Parquet fields. */ +public final class ParquetPhysicalReader implements PhysicalReader { + private static final Set<ReadCapability> CAPABILITIES = + Collections.unmodifiableSet( + EnumSet.of( + ReadCapability.PROJECTION, + ReadCapability.ROW_RANGE, + ReadCapability.LIMIT)); + + private static final int DEFAULT_FOOTER_CACHE_CAPACITY = 256; + + private final Storage storage; + private final FooterCache footers; + + /** + * Creates a reader that resolves each request URI through {@code storage} and remembers the + * footers of recently read files. + */ + public ParquetPhysicalReader(Storage storage) { + this(storage, DEFAULT_FOOTER_CACHE_CAPACITY); + } + + /** + * Creates a reader that keeps at most {@code footerCacheCapacity} Parquet footers in memory. + * GraphAr chunk files are immutable once published, so repeated range reads of one chunk parse + * its footer once. A capacity of zero parses the footer on every request. + * + * @param storage resolves each request URI to a readable file + * @param footerCacheCapacity maximum number of remembered footers + */ + public ParquetPhysicalReader(Storage storage, int footerCacheCapacity) { + this.storage = Objects.requireNonNull(storage, "storage"); + this.footers = new FooterCache(footerCacheCapacity); + } + + @Override + public Set<ReadCapability> capabilities() { + return CAPABILITIES; + } + + @Override + public ReadResult read(ReadRequest request) throws IOException { + Objects.requireNonNull(request, "request"); + if (!request.filters().isEmpty()) { + throw new UnsupportedOperationException( + "Parquet filter pushdown is not implemented; refusing semantic fallback."); + } + InputFile inputFile = + Objects.requireNonNull(storage.inputFile(request.uri()), "storage inputFile"); + ParquetFileReader fileReader = null; + try { + fileReader = open(request.uri(), new ParquetInputFile(inputFile)); + MessageType fileSchema = fileReader.getFooter().getFileMetaData().getSchema(); + List<ParquetColumn> fileColumns = columns(fileSchema); + Map<String, ParquetColumn> columnsByName = byName(fileColumns); + List<ParquetColumn> outputColumns = outputColumns(request, fileColumns, columnsByName); + List<ParquetColumn> readColumns = readColumns(fileColumns, outputColumns); + MessageType readSchema = + new MessageType(fileSchema.getName(), parquetTypes(readColumns)); + fileReader.setRequestedSchema(readSchema); + + Schema outputSchema = new Schema(fields(outputColumns)); + ReadReport report = new ReadReport(applied(request), declined(request)); + ParquetBatchCursor cursor = + new ParquetBatchCursor( + fileReader, + fileSchema, + readSchema, + readColumns, + outputColumns, + outputSchema, + request); + fileReader = null; + return new ReadResult(request, cursor, report); + } finally { + if (fileReader != null) { + fileReader.close(); + } + } + } + + /** + * Opens a Parquet reader, reusing a remembered footer when the file is unchanged in size. The + * returned reader owns the stream opened here and closes it. + */ + private ParquetFileReader open(URI uri, ParquetInputFile file) throws IOException { + ParquetReadOptions options = ParquetReadOptions.builder().build(); + long size = file.getLength(); + ParquetMetadata remembered = footers.get(uri, size); + SeekableInputStream stream = file.newStream(); + try { + ParquetMetadata footer = remembered; + if (footer == null) { + footer = ParquetFileReader.readFooter(file, options, stream); + if (isPlaintext(footer)) { + footers.put(uri, size, footer); + } + } + ParquetFileReader reader = ParquetFileReader.open(file, footer, options, stream); + stream = null; + return reader; + } finally { + if (stream != null) { + stream.close(); + } + } + } + + /** + * Returns whether a footer is safe to remember. An encrypted file carries a stateful decryptor + * inside its metadata, so its footer is parsed again for every reader. + */ + private static boolean isPlaintext(ParquetMetadata footer) { + return footer.getFileMetaData().getFileDecryptor() == null; + } + + private static List<ParquetColumn> columns(MessageType fileSchema) { + List<ParquetColumn> columns = new ArrayList<>(); + for (Type type : fileSchema.getFields()) { + if (type.isRepetition(Type.Repetition.REPEATED) + || (!type.isPrimitive() && !isList(type))) { + throw new IllegalArgumentException( + "Only primitive or standard LIST Parquet columns are supported: " + + type.getName()); + } + columns.add(new ParquetColumn(type, toField(type))); + } + return List.copyOf(columns); + } + + private static Map<String, ParquetColumn> byName(List<ParquetColumn> columns) { + Map<String, ParquetColumn> columnsByName = new HashMap<>(); + for (ParquetColumn column : columns) { + if (columnsByName.put(column.field().name(), column) != null) { + throw new IllegalArgumentException( + "Duplicate Parquet column: " + column.field().name()); + } + } + return columnsByName; + } + + private static List<ParquetColumn> outputColumns( + ReadRequest request, + List<ParquetColumn> fileColumns, + Map<String, ParquetColumn> columnsByName) { + if (request.projection().isAllColumns()) { + return fileColumns; + } + List<ParquetColumn> result = new ArrayList<>(); + for (ColumnRef reference : request.projection().columns()) { + ParquetColumn column = columnsByName.get(reference.name()); + if (column == null) { + throw new IllegalArgumentException("Unknown projection column: " + reference); + } + result.add(column); + } + return List.copyOf(result); + } + + private static List<ParquetColumn> readColumns( + List<ParquetColumn> fileColumns, List<ParquetColumn> outputColumns) { + Map<String, ParquetColumn> needed = new LinkedHashMap<>(); + for (ParquetColumn column : outputColumns) { + needed.put(column.field().name(), column); + } + List<ParquetColumn> result = new ArrayList<>(); + for (ParquetColumn column : fileColumns) { + if (needed.containsKey(column.field().name())) { + result.add(column); + } + } + return List.copyOf(result); + } + + private static List<Type> parquetTypes(List<ParquetColumn> columns) { + List<Type> types = new ArrayList<>(); + for (ParquetColumn column : columns) { + types.add(column.parquetType()); + } + return types; + } + + private static List<Field> fields(List<ParquetColumn> columns) { + List<Field> fields = new ArrayList<>(); + for (ParquetColumn column : columns) { + fields.add(column.field()); + } + return fields; + } + + private static Set<ReadCapability> applied(ReadRequest request) { + EnumSet<ReadCapability> applied = EnumSet.noneOf(ReadCapability.class); + if (!request.projection().isAllColumns()) { + applied.add(ReadCapability.PROJECTION); + } + if (request.rowRange().isPresent()) { + applied.add(ReadCapability.ROW_RANGE); + } + if (request.limit().isPresent()) { + applied.add(ReadCapability.LIMIT); + } + return applied; + } + + private static Set<ReadCapability> declined(ReadRequest request) { + return Collections.emptySet(); + } + + private static boolean isList(Type type) { + return type.getLogicalTypeAnnotation() + instanceof LogicalTypeAnnotation.ListLogicalTypeAnnotation; + } + + private static Field toField(Type type) { + return new Field(type.getName(), type(type), !type.isRepetition(Type.Repetition.REQUIRED)); + } + + private static ColumnType type(Type type) { + if (!type.isPrimitive()) { + return listType(type); + } + PrimitiveType primitive = type.asPrimitiveType(); + return primitiveType(primitive); + } + + private static ColumnType listType(Type type) { + if (!isList(type) || type.asGroupType().getFieldCount() != 1) { + throw new IllegalArgumentException("Unsupported Parquet LIST field: " + type); + } + Type repeated = type.asGroupType().getType(0); + if (!repeated.isRepetition(Type.Repetition.REPEATED) + || repeated.isPrimitive() + || repeated.asGroupType().getFieldCount() != 1) { + throw new IllegalArgumentException("Unsupported Parquet LIST field: " + type); + } + Type element = repeated.asGroupType().getType(0); + if (!element.isPrimitive() || !element.isRepetition(Type.Repetition.REPEATED)) { Review Comment: This requires the LIST element to be REPEATED, but the Parquet spec's 3-level LIST layout defines the element as REQUIRED or OPTIONAL. Files written by Spark/Arrow/pyarrow are therefore rejected here at schema-mapping time, contradicting the "Only primitive or standard LIST Parquet columns are supported" message in columns(). Note that ParquetBatchCursor.listValue already reads values through the middle repeated "list" wrapper group, so accepting spec-conformant elements mainly needs this check relaxed plus null-element handling (element repetition count 0). Also note this check and `ParquetPhysicalWriter.listElementType` must be changed together: the writer currently emits a REPEATED element, so today the module only round-trips its own non-standard layout. ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetPhysicalWriter.java: ########## @@ -0,0 +1,288 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import org.apache.graphar.io.BatchCursor; +import org.apache.graphar.io.ColumnType; +import org.apache.graphar.io.Field; +import org.apache.graphar.io.PhysicalWriter; +import org.apache.graphar.io.RecordBatch; +import org.apache.graphar.io.Schema; +import org.apache.graphar.io.WriteMode; +import org.apache.graphar.io.WriteRequest; +import org.apache.graphar.storage.Storage; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.SimpleGroupFactory; +import org.apache.parquet.hadoop.ParquetFileWriter; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.parquet.schema.Types; + +/** A storage-backed writer for flat, primitive Parquet batches. */ +public final class ParquetPhysicalWriter implements PhysicalWriter { + private static final int INDEXED_PAGE_ROW_COUNT = 1024; + + private final Storage storage; + + /** + * Maps the request mode onto a Parquet file mode. Parquet cannot append to a closed file + * without rewriting it, so {@link WriteMode#APPEND} is rejected instead of being downgraded to + * an overwrite. + */ + static ParquetFileWriter.Mode fileMode(WriteMode mode) { + switch (Objects.requireNonNull(mode, "mode")) { + case CREATE_NEW: + return ParquetFileWriter.Mode.CREATE; + case OVERWRITE: + return ParquetFileWriter.Mode.OVERWRITE; + default: + throw new UnsupportedOperationException( + "Parquet cannot append to an existing file; write mode " + mode); + } + } + + /** Creates a writer that resolves every output URI through {@code storage}. */ + public ParquetPhysicalWriter(Storage storage) { + this.storage = Objects.requireNonNull(storage, "storage"); + } + + @Override + public void write(WriteRequest request, BatchCursor batches) throws IOException { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(batches, "batches"); + MessageType parquetSchema = parquetSchema(request.schema()); + ParquetFileWriter.Mode mode = fileMode(request.mode()); + try (ParquetWriter<Group> writer = + ExampleParquetWriter.builder( + new ParquetOutputFile(storage.outputFile(request.uri()))) + .withType(parquetSchema) + .withWriteMode(mode) + .withPageRowCountLimit(INDEXED_PAGE_ROW_COUNT) + .build()) { + SimpleGroupFactory groups = new SimpleGroupFactory(parquetSchema); + while (batches.next()) { + RecordBatch batch = + Objects.requireNonNull(batches.batch(), "batch cursor returned null"); + requireSchema(request.schema(), batch.schema()); + for (int rowIndex = 0; rowIndex < batch.rowCount(); rowIndex++) { + writer.write(toGroup(groups, request.schema(), batch, rowIndex)); + } + } + } finally { + batches.close(); + } + } + + private static MessageType parquetSchema(Schema schema) { + Types.MessageTypeBuilder builder = Types.buildMessage(); + for (Field field : schema.fields()) { + if (field.type().kind() == ColumnType.Kind.LIST) { + builder.addField(listType(field)); + continue; + } + Types.PrimitiveBuilder<Types.GroupBuilder<MessageType>> primitive = + builder.primitive(physicalType(field.type()), repetition(field)); + LogicalTypeAnnotation logicalType = logicalType(field.type()); + if (logicalType != null) { + primitive.as(logicalType); + } + primitive.named(field.name()); + } + return builder.named("graphar"); + } + + private static Type listType(Field field) { + ColumnType element = field.type().elementType().orElseThrow(); + Type value = listElementType(element); + return (field.nullable() ? Types.optionalList() : Types.requiredList()) + .element(value) + .named(field.name()); + } + + private static Type listElementType(ColumnType element) { + LogicalTypeAnnotation logical = logicalType(element); + if (logical == null) { + return Types.repeated(physicalType(element)).named("element"); Review Comment: See the comment in Reader about "repeated" ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetBatchCursor.java: ########## @@ -0,0 +1,350 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.graphar.io.BatchCursor; +import org.apache.graphar.io.ReadRequest; +import org.apache.graphar.io.RecordBatch; +import org.apache.graphar.io.RowRange; +import org.apache.graphar.io.Schema; +import org.apache.graphar.io.ValueVector; +import org.apache.graphar.io.VectorRecordBatch; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.convert.GroupRecordConverter; +import org.apache.parquet.filter2.columnindex.RowRanges; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.BlockMetaData; +import org.apache.parquet.internal.filter2.columnindex.ColumnIndexStore.MissingOffsetIndexException; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.RecordReader; +import org.apache.parquet.schema.MessageType; + +/** Streams materialized Parquet row groups as neutral record batches. */ +final class ParquetBatchCursor implements BatchCursor { + private static final int BATCH_ROWS = 1_024; + private final ParquetFileReader fileReader; + private final MessageType fileSchema; + private final MessageType readSchema; + private final List<ParquetColumn> readColumns; + private final Schema outputSchema; + private final Map<String, Integer> readColumnIndexes; + private final int[] outputIndexes; + private final long rangeStart; + private final long rangeEnd; + private final long limit; + private final List<BlockRange> rowGroups; + private int nextRowGroup; + private long emitted; + private long rowsRemainingInGroup; + private PageReadStore pages; + private RecordReader<Group> rows; + private boolean exhausted; + private boolean closed; + private RecordBatch current; + + ParquetBatchCursor( + ParquetFileReader fileReader, + MessageType fileSchema, + MessageType readSchema, + List<ParquetColumn> readColumns, + List<ParquetColumn> outputColumns, + Schema outputSchema, + ReadRequest request) { + this.fileReader = fileReader; + this.fileSchema = fileSchema; + this.readSchema = readSchema; + this.readColumns = readColumns; + this.outputSchema = outputSchema; + this.readColumnIndexes = indexes(readColumns); + this.outputIndexes = outputIndexes(outputColumns, readColumnIndexes); + RowRange range = request.rowRange().orElse(null); + this.rangeStart = range == null ? 0 : range.startInclusive(); + this.rangeEnd = range == null ? Long.MAX_VALUE : range.endExclusive(); + this.limit = request.limit().isPresent() ? request.limit().getAsLong() : Long.MAX_VALUE; + this.rowGroups = rowGroups(fileReader.getRowGroups()); + } + + @Override + public boolean next() throws IOException { + if (closed || exhausted) { + current = null; + return false; + } + if (emitted == limit) { Review Comment: Two issues: (1) for limit > 0 this branch is unreachable — reaching the limit inside the batch loop already sets exhausted = true, so the `closed || exhausted` check above returns false first; the only reachable case is limit == 0. (2) When the limit is reached mid-scan, next() sets exhausted without closing the ParquetFileReader or any open pages (unlike natural exhaustion, which goes through finish()), so after next() returns false the cursor is in different release states depending on the exit path; a caller that stops at the first false return without calling close() leaks the file handle. Suggest closing the reader when the limit is reached while keeping `current` retrievable so batch() stays valid after the final true return. ########## maven-projects/io-parquet/pom.xml: ########## @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +--> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.graphar</groupId> + <artifactId>graphar-root</artifactId> + <version>${graphar.version}</version> + <relativePath>../pom.xml</relativePath> + </parent> + + <artifactId>graphar-io-parquet</artifactId> + <packaging>jar</packaging> + <version>${graphar.version}</version> + + <name>graphar-io-parquet</name> + + <properties> + <maven.compiler.source>11</maven.compiler.source> + <maven.compiler.target>11</maven.compiler.target> + <hadoop.version>3.3.0</hadoop.version> Review Comment: The latest is 3.5.0 (https://mvnrepository.com/artifact/org.apache.hadoop/hadoop-common/3.5.0), what was the reason of pinning 3.3.0? ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetOutputFile.java: ########## @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.util.Objects; +import org.apache.graphar.storage.OutputFile; +import org.apache.graphar.storage.PositionOutput; +import org.apache.parquet.io.PositionOutputStream; + +/** Adapts a GraphAr storage output file to Parquet's output-file interface. */ +final class ParquetOutputFile implements org.apache.parquet.io.OutputFile { + private final OutputFile outputFile; + + ParquetOutputFile(OutputFile outputFile) { + this.outputFile = Objects.requireNonNull(outputFile, "outputFile"); + } + + @Override + public PositionOutputStream create(long blockSizeHint) throws IOException { + return new ParquetPositionOutputStream(outputFile.create()); + } + + @Override + public PositionOutputStream createOrOverwrite(long blockSizeHint) throws IOException { + return new ParquetPositionOutputStream(outputFile.createOrOverwrite()); + } + + @Override + public boolean supportsBlockSize() { + return false; + } + + @Override + public long defaultBlockSize() { + return 0; + } + + @Override + public String getPath() { + return outputFile.uri().toString(); + } + + private static final class ParquetPositionOutputStream extends PositionOutputStream { + private final PositionOutput output; + + private ParquetPositionOutputStream(PositionOutput output) { + this.output = output; + } + + @Override + public long getPos() throws IOException { + return output.position(); + } + + @Override + public void write(int value) throws IOException { + output.write(new byte[] {(byte) value}); Review Comment: This allocates a fresh one-byte array on every single-byte write. Parquet serializes many small values (page headers, variant lengths, footer metadata) through write(int), producing avoidable garbage per file. A reusable one-byte buffer is safe here because the writer stream is used by a single thread. ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetBatchCursor.java: ########## @@ -0,0 +1,350 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.graphar.io.BatchCursor; +import org.apache.graphar.io.ReadRequest; +import org.apache.graphar.io.RecordBatch; +import org.apache.graphar.io.RowRange; +import org.apache.graphar.io.Schema; +import org.apache.graphar.io.ValueVector; +import org.apache.graphar.io.VectorRecordBatch; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.convert.GroupRecordConverter; +import org.apache.parquet.filter2.columnindex.RowRanges; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.BlockMetaData; +import org.apache.parquet.internal.filter2.columnindex.ColumnIndexStore.MissingOffsetIndexException; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.RecordReader; +import org.apache.parquet.schema.MessageType; + +/** Streams materialized Parquet row groups as neutral record batches. */ +final class ParquetBatchCursor implements BatchCursor { + private static final int BATCH_ROWS = 1_024; + private final ParquetFileReader fileReader; + private final MessageType fileSchema; + private final MessageType readSchema; + private final List<ParquetColumn> readColumns; + private final Schema outputSchema; + private final Map<String, Integer> readColumnIndexes; + private final int[] outputIndexes; + private final long rangeStart; + private final long rangeEnd; + private final long limit; + private final List<BlockRange> rowGroups; + private int nextRowGroup; + private long emitted; + private long rowsRemainingInGroup; + private PageReadStore pages; + private RecordReader<Group> rows; + private boolean exhausted; + private boolean closed; + private RecordBatch current; + + ParquetBatchCursor( + ParquetFileReader fileReader, + MessageType fileSchema, + MessageType readSchema, + List<ParquetColumn> readColumns, + List<ParquetColumn> outputColumns, + Schema outputSchema, + ReadRequest request) { + this.fileReader = fileReader; + this.fileSchema = fileSchema; + this.readSchema = readSchema; + this.readColumns = readColumns; + this.outputSchema = outputSchema; + this.readColumnIndexes = indexes(readColumns); + this.outputIndexes = outputIndexes(outputColumns, readColumnIndexes); + RowRange range = request.rowRange().orElse(null); + this.rangeStart = range == null ? 0 : range.startInclusive(); + this.rangeEnd = range == null ? Long.MAX_VALUE : range.endExclusive(); + this.limit = request.limit().isPresent() ? request.limit().getAsLong() : Long.MAX_VALUE; + this.rowGroups = rowGroups(fileReader.getRowGroups()); + } + + @Override + public boolean next() throws IOException { + if (closed || exhausted) { + current = null; + return false; + } + if (emitted == limit) { + finish(); + return false; + } + try { + while (true) { + if (rows == null && !openNextRowGroup()) { + finish(); + return false; + } + int batchSize = + (int) Math.min(Math.min(rowsRemainingInGroup, BATCH_ROWS), limit - emitted); + Object[][] columns = new Object[outputIndexes.length][batchSize]; + for (int index = 0; index < batchSize; index++) { + Group group = rows.read(); + project(values(group), columns, index); + emitted++; + rowsRemainingInGroup--; + } + if (rowsRemainingInGroup == 0) { + closePages(); + } + current = batch(columns, batchSize); + if (emitted == limit) exhausted = true; + return true; + } + } catch (MissingOffsetIndexException exception) { + try { + closeReader(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw new UnsupportedOperationException( + "Physical Parquet row ranges require an Offset Index; refusing JVM fallback.", + exception); + } catch (IOException | RuntimeException exception) { + try { + closeReader(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw exception; + } + } + + @Override + public RecordBatch batch() { + if (current == null) { + throw new IllegalStateException("No current batch. Call next() before batch()."); + } + return current; + } + + @Override + public void close() throws IOException { + current = null; + exhausted = true; + closeReader(); + } + + private Object[] values(Group group) { + Object[] values = new Object[readColumns.size()]; + for (int index = 0; index < readColumns.size(); index++) { + if (group.getFieldRepetitionCount(index) != 0) { + values[index] = value(group, index, readColumns.get(index)); + } + } + return values; + } + + private static Object value(Group group, int index, ParquetColumn column) { + if (column.field().type().kind() == org.apache.graphar.io.ColumnType.Kind.LIST) { + return listValue(group, index, column); + } + return scalarValue(group, index, column.field().type().kind()); + } + + private static List<Object> listValue(Group group, int index, ParquetColumn column) { + Group list = group.getGroup(index, 0); + if (list.getType().getFieldCount() != 1) { + throw new IllegalArgumentException( + "Unsupported Parquet LIST field: " + column.field().name()); + } + int count = list.getFieldRepetitionCount(0); + if (count == 0) { + return List.of(); + } + List<Object> values = new ArrayList<>(count); + org.apache.graphar.io.ColumnType element = + column.field().type().elementType().orElseThrow(); + for (int elementIndex = 0; elementIndex < count; elementIndex++) { + Group elementGroup = list.getGroup(0, elementIndex); Review Comment: The variable names are inverted relative to the Parquet spec terminology, which makes the element-extraction logic hard to verify: `list` holds the LIST group instance (the outer group), while `elementGroup` actually holds one instance of the middle repeated "list" wrapper group, not the element itself. The logic reads correctly (each wrapper instance contains one primitive element), but the naming suggests the primitive element is being treated as a group. ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetBatchCursor.java: ########## @@ -0,0 +1,350 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.graphar.io.BatchCursor; +import org.apache.graphar.io.ReadRequest; +import org.apache.graphar.io.RecordBatch; +import org.apache.graphar.io.RowRange; +import org.apache.graphar.io.Schema; +import org.apache.graphar.io.ValueVector; +import org.apache.graphar.io.VectorRecordBatch; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.convert.GroupRecordConverter; +import org.apache.parquet.filter2.columnindex.RowRanges; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.BlockMetaData; +import org.apache.parquet.internal.filter2.columnindex.ColumnIndexStore.MissingOffsetIndexException; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.RecordReader; +import org.apache.parquet.schema.MessageType; + +/** Streams materialized Parquet row groups as neutral record batches. */ +final class ParquetBatchCursor implements BatchCursor { + private static final int BATCH_ROWS = 1_024; + private final ParquetFileReader fileReader; + private final MessageType fileSchema; + private final MessageType readSchema; + private final List<ParquetColumn> readColumns; + private final Schema outputSchema; + private final Map<String, Integer> readColumnIndexes; + private final int[] outputIndexes; + private final long rangeStart; + private final long rangeEnd; + private final long limit; + private final List<BlockRange> rowGroups; + private int nextRowGroup; + private long emitted; + private long rowsRemainingInGroup; + private PageReadStore pages; + private RecordReader<Group> rows; + private boolean exhausted; + private boolean closed; + private RecordBatch current; + + ParquetBatchCursor( + ParquetFileReader fileReader, + MessageType fileSchema, + MessageType readSchema, + List<ParquetColumn> readColumns, + List<ParquetColumn> outputColumns, + Schema outputSchema, + ReadRequest request) { + this.fileReader = fileReader; + this.fileSchema = fileSchema; + this.readSchema = readSchema; + this.readColumns = readColumns; + this.outputSchema = outputSchema; + this.readColumnIndexes = indexes(readColumns); + this.outputIndexes = outputIndexes(outputColumns, readColumnIndexes); + RowRange range = request.rowRange().orElse(null); + this.rangeStart = range == null ? 0 : range.startInclusive(); + this.rangeEnd = range == null ? Long.MAX_VALUE : range.endExclusive(); + this.limit = request.limit().isPresent() ? request.limit().getAsLong() : Long.MAX_VALUE; + this.rowGroups = rowGroups(fileReader.getRowGroups()); + } + + @Override + public boolean next() throws IOException { + if (closed || exhausted) { + current = null; + return false; + } + if (emitted == limit) { + finish(); + return false; + } + try { + while (true) { + if (rows == null && !openNextRowGroup()) { + finish(); + return false; + } + int batchSize = + (int) Math.min(Math.min(rowsRemainingInGroup, BATCH_ROWS), limit - emitted); + Object[][] columns = new Object[outputIndexes.length][batchSize]; + for (int index = 0; index < batchSize; index++) { + Group group = rows.read(); + project(values(group), columns, index); + emitted++; + rowsRemainingInGroup--; + } + if (rowsRemainingInGroup == 0) { + closePages(); + } + current = batch(columns, batchSize); + if (emitted == limit) exhausted = true; + return true; + } + } catch (MissingOffsetIndexException exception) { + try { + closeReader(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw new UnsupportedOperationException( + "Physical Parquet row ranges require an Offset Index; refusing JVM fallback.", + exception); + } catch (IOException | RuntimeException exception) { + try { + closeReader(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw exception; + } + } + + @Override + public RecordBatch batch() { + if (current == null) { + throw new IllegalStateException("No current batch. Call next() before batch()."); + } + return current; + } + + @Override + public void close() throws IOException { + current = null; + exhausted = true; + closeReader(); + } + + private Object[] values(Group group) { + Object[] values = new Object[readColumns.size()]; + for (int index = 0; index < readColumns.size(); index++) { + if (group.getFieldRepetitionCount(index) != 0) { + values[index] = value(group, index, readColumns.get(index)); + } + } + return values; + } + + private static Object value(Group group, int index, ParquetColumn column) { + if (column.field().type().kind() == org.apache.graphar.io.ColumnType.Kind.LIST) { + return listValue(group, index, column); + } + return scalarValue(group, index, column.field().type().kind()); + } + + private static List<Object> listValue(Group group, int index, ParquetColumn column) { + Group list = group.getGroup(index, 0); + if (list.getType().getFieldCount() != 1) { + throw new IllegalArgumentException( + "Unsupported Parquet LIST field: " + column.field().name()); + } + int count = list.getFieldRepetitionCount(0); + if (count == 0) { + return List.of(); + } + List<Object> values = new ArrayList<>(count); + org.apache.graphar.io.ColumnType element = + column.field().type().elementType().orElseThrow(); + for (int elementIndex = 0; elementIndex < count; elementIndex++) { + Group elementGroup = list.getGroup(0, elementIndex); + if (elementGroup.getType().getFieldCount() != 1 + || elementGroup.getFieldRepetitionCount(0) != 1) { + throw new IllegalArgumentException( + "Unsupported Parquet LIST element: " + column.field().name()); + } + values.add(scalarValue(elementGroup, 0, element, 0)); + } + return Collections.unmodifiableList(values); + } + + private static Object scalarValue( + Group group, int index, org.apache.graphar.io.ColumnType.Kind kind) { + return scalarValue(group, index, org.apache.graphar.io.ColumnType.of(kind), 0); + } + + private static Object scalarValue( + Group group, int index, org.apache.graphar.io.ColumnType type, int repetitionIndex) { + switch (type.kind()) { + case BOOLEAN: + return group.getBoolean(index, repetitionIndex); + case INT8: + return (byte) group.getInteger(index, repetitionIndex); + case INT16: + return (short) group.getInteger(index, repetitionIndex); + case INT32: + return group.getInteger(index, repetitionIndex); + case INT64: + return group.getLong(index, repetitionIndex); + case FLOAT32: + return group.getFloat(index, repetitionIndex); + case FLOAT64: + return group.getDouble(index, repetitionIndex); + case STRING: + return group.getBinary(index, repetitionIndex).toStringUsingUTF8(); + case BINARY: + return group.getBinary(index, repetitionIndex).getBytes(); + case DATE: + return LocalDate.ofEpochDay(group.getInteger(index, repetitionIndex)); + case TIMESTAMP_MILLIS: + return Instant.ofEpochMilli(group.getLong(index, repetitionIndex)); + default: + throw new IllegalArgumentException( + "Unsupported Parquet column type: " + type.kind()); + } + } + + private void project(Object[] values, Object[][] columns, int row) { + for (int index = 0; index < outputIndexes.length; index++) { + Object value = values[outputIndexes[index]]; + columns[index][row] = value instanceof byte[] ? ((byte[]) value).clone() : value; + } + } + + private RecordBatch batch(Object[][] columns, int rowCount) { + List<ValueVector> vectors = new ArrayList<>(columns.length); + for (int index = 0; index < columns.length; index++) { + vectors.add(new ParquetValueVector(outputSchema.fields().get(index), columns[index])); + } + return new VectorRecordBatch(outputSchema, vectors, rowCount); + } + + private void finish() throws IOException { + exhausted = true; + current = null; + closeReader(); + } + + private void closeReader() throws IOException { + if (!closed) { + closed = true; + try { + closePages(); + } finally { + fileReader.close(); + } + } + } + + private boolean openNextRowGroup() throws IOException { + BlockRange rowGroup = nextRange(); + if (rowGroup == null) return false; + pages = Review Comment: Row-group reads always go through `readFilteredRowGroup` with a RowRanges object, which requires offset indexes in the file. When no row range was requested (rangeStart=0, rangeEnd=MAX_VALUE) or the range covers the whole group, this still forces the offset-index code path, so a plain full scan of a Parquet file without offset indexes (older or foreign writers) throws `MissingOffsetIndexException` and surfaces as `UnsupportedOperationException` — even though no filtering is needed. The catch message "Physical Parquet row ranges require an Offset Index" is misleading for the same reason. There is no non-filtered fallback in this module; suggest calling `fileReader.readRowGroup(rowGroup.index)` when the selection covers the entire row group (a flag computed in nextRange(), where the absolute bounds are known). ########## maven-projects/io-parquet/src/main/java/org/apache/graphar/io/parquet/ParquetPhysicalReader.java: ########## @@ -0,0 +1,426 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.graphar.io.parquet; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.graphar.io.ColumnRef; +import org.apache.graphar.io.ColumnType; +import org.apache.graphar.io.Field; +import org.apache.graphar.io.PhysicalReader; +import org.apache.graphar.io.ReadCapability; +import org.apache.graphar.io.ReadReport; +import org.apache.graphar.io.ReadRequest; +import org.apache.graphar.io.ReadResult; +import org.apache.graphar.io.Schema; +import org.apache.graphar.storage.InputFile; +import org.apache.graphar.storage.Storage; +import org.apache.parquet.ParquetReadOptions; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.SeekableInputStream; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; + +/** A storage-backed reader for GraphAr primitive and LIST Parquet fields. */ +public final class ParquetPhysicalReader implements PhysicalReader { + private static final Set<ReadCapability> CAPABILITIES = + Collections.unmodifiableSet( + EnumSet.of( + ReadCapability.PROJECTION, + ReadCapability.ROW_RANGE, + ReadCapability.LIMIT)); + + private static final int DEFAULT_FOOTER_CACHE_CAPACITY = 256; + + private final Storage storage; + private final FooterCache footers; + + /** + * Creates a reader that resolves each request URI through {@code storage} and remembers the + * footers of recently read files. + */ + public ParquetPhysicalReader(Storage storage) { + this(storage, DEFAULT_FOOTER_CACHE_CAPACITY); + } + + /** + * Creates a reader that keeps at most {@code footerCacheCapacity} Parquet footers in memory. + * GraphAr chunk files are immutable once published, so repeated range reads of one chunk parse + * its footer once. A capacity of zero parses the footer on every request. + * + * @param storage resolves each request URI to a readable file + * @param footerCacheCapacity maximum number of remembered footers + */ + public ParquetPhysicalReader(Storage storage, int footerCacheCapacity) { + this.storage = Objects.requireNonNull(storage, "storage"); + this.footers = new FooterCache(footerCacheCapacity); + } + + @Override + public Set<ReadCapability> capabilities() { + return CAPABILITIES; + } + + @Override + public ReadResult read(ReadRequest request) throws IOException { + Objects.requireNonNull(request, "request"); + if (!request.filters().isEmpty()) { + throw new UnsupportedOperationException( + "Parquet filter pushdown is not implemented; refusing semantic fallback."); + } + InputFile inputFile = + Objects.requireNonNull(storage.inputFile(request.uri()), "storage inputFile"); + ParquetFileReader fileReader = null; + try { + fileReader = open(request.uri(), new ParquetInputFile(inputFile)); + MessageType fileSchema = fileReader.getFooter().getFileMetaData().getSchema(); + List<ParquetColumn> fileColumns = columns(fileSchema); + Map<String, ParquetColumn> columnsByName = byName(fileColumns); + List<ParquetColumn> outputColumns = outputColumns(request, fileColumns, columnsByName); + List<ParquetColumn> readColumns = readColumns(fileColumns, outputColumns); + MessageType readSchema = + new MessageType(fileSchema.getName(), parquetTypes(readColumns)); + fileReader.setRequestedSchema(readSchema); + + Schema outputSchema = new Schema(fields(outputColumns)); + ReadReport report = new ReadReport(applied(request), declined(request)); + ParquetBatchCursor cursor = + new ParquetBatchCursor( + fileReader, + fileSchema, + readSchema, + readColumns, + outputColumns, + outputSchema, + request); + fileReader = null; + return new ReadResult(request, cursor, report); + } finally { + if (fileReader != null) { + fileReader.close(); + } + } + } + + /** + * Opens a Parquet reader, reusing a remembered footer when the file is unchanged in size. The + * returned reader owns the stream opened here and closes it. + */ + private ParquetFileReader open(URI uri, ParquetInputFile file) throws IOException { + ParquetReadOptions options = ParquetReadOptions.builder().build(); + long size = file.getLength(); + ParquetMetadata remembered = footers.get(uri, size); Review Comment: The footer cache keys entries only by (uri, file size), so a file overwritten in place with different content of the same length is parsed with a stale footer — wrong schema, wrong row-group offsets, corrupt reads. The "chunk files are immutable" javadoc assumption is not enforced: the storage API exposes `OutputFile.createOrOverwrite()` (storage-api, storage-local, storage-s3) and this module's own writer accepts `WriteMode.OVERWRITE`. Consider invalidating cache entries on write paths, keying on a stronger signal, or at minimum documenting the immutability contract where the cache is defined. -- 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]
