Repository navigation
feat(operator): read a Parquet file as a source #8512
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
Draft
kz930
wants to merge
8
commits into
apache:main
Choose a base branch
from
kz930:feat/parquet-source
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
62cf952
feat(operator): read a Parquet file as a source
kz930 04036a0
feat(operator): read a Parquet decimal and keep a timestamp's precision
kz930 cc66f8a
feat(operator): read the Parquet columns pandas reads differently
kz930 0b34f58
fix(operator): name the parquet source's file the way the source expo…
kz930 e218130
fix(operator): read a Parquet file into the dtypes that keep its nulls
kz930 fcd65b6
fix(operator): read every column a Parquet footer states, index or not
kz930 3b9fa88
fix(operator): read Parquet dates, narrow integers and negative windo…
kz930 d2836ed
test(operator): run the cases that execute the exported code in amber…
kz930 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
182 changes: 182 additions & 0 deletions
182
.../scala/org/apache/texera/amber/operator/source/scan/parquet/ParquetScanSourceOpDesc.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| /* | ||
| * 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.texera.amber.operator.source.scan.parquet | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties | ||
| import org.apache.parquet.hadoop.ParquetFileReader | ||
| import org.apache.parquet.io.LocalInputFile | ||
| import org.apache.texera.amber.core.executor.OpExecWithClassName | ||
| import org.apache.texera.amber.core.storage.DocumentFactory | ||
| import org.apache.texera.amber.core.tuple.Schema | ||
| import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity} | ||
| import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc} | ||
| import org.apache.texera.amber.operator.StandaloneCodeGenerator | ||
| import org.apache.texera.amber.operator.StandaloneCodeGenerator.SourceFilePlaceholder | ||
| import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc | ||
| import org.apache.texera.amber.util.JSONUtils.objectMapper | ||
|
|
||
| import java.io.IOException | ||
| import java.net.URI | ||
| import scala.util.Using | ||
|
|
||
| /** | ||
| * Reads a Parquet file. The format states its own column types in a footer, so | ||
| * unlike the CSV and JSONL sources this one infers nothing: the INTEGER the | ||
| * file was written with is the INTEGER that arrives. | ||
| */ | ||
| @JsonIgnoreProperties(value = Array("fileEncoding")) | ||
| class ParquetScanSourceOpDesc extends ScanSourceOpDesc with StandaloneCodeGenerator { | ||
|
|
||
| fileTypeName = Option("Parquet") | ||
|
|
||
| // A DECIMAL is the one column pandas does not land on the same type as the | ||
| // executor: it fills that column with decimal.Decimal objects, which a script | ||
| // cannot then multiply by a float. pyarrow is what pandas reads a Parquet file | ||
| // with; the footer is asked for directly here to read it the executor's way. | ||
| override def standaloneImports(): Seq[String] = | ||
| Seq("from datetime import date", "from decimal import Decimal", "import pyarrow.parquet as pq") | ||
|
|
||
| override def standaloneSourcePath(): Option[String] = fileName | ||
|
|
||
| override def generateStandaloneCode(): String = { | ||
| // No timestamps to parse, and no dtype map. pandas reads the types out of | ||
| // the same footer the executor does, which is the whole point of the format; | ||
| // the text formats parse theirs because they carry nothing to read. | ||
| // Read into the nullable dtypes, as the Arrow source is. Parquet says of | ||
| // every value whether it is there, and a numpy column has nowhere to put | ||
| // that: pandas widens a holed integer column through a float, where every | ||
| // value past 2^53 is rounded and 9007199254740993 came back as ...992. The | ||
| // executor reads the exact long, and the declared column stays integral. | ||
| // | ||
| // The footer is handed back to the reader stripped of its metadata. A file | ||
| // pandas wrote records there which of its columns held the frame's index, | ||
| // and pandas restores those as an index rather than as columns: a file | ||
| // written from a frame keyed by `customer_id` reads back without that | ||
| // column, and a projection naming it raises. The executor reads the columns | ||
| // the footer states and knows nothing of an index, so it hands `customer_id` | ||
| // on like any other. Stripped, pandas reads every column the file holds, | ||
| // under the name the footer gives it and in the order it states them. | ||
| val read = | ||
| s"""|out1df = pd.read_parquet( | ||
| | $SourceFilePlaceholder, | ||
| | dtype_backend="numpy_nullable", | ||
| | schema=pq.read_schema($SourceFilePlaceholder).remove_metadata(), | ||
| |)""".stripMargin | ||
| // The columns pandas does not land on the same value as the executor. A | ||
| // DECIMAL arrives as decimal.Decimal objects and a FLOAT keeps the single | ||
| // precision the executor widens; an unsigned column, a duration and a zoned | ||
| // timestamp come back as the Arrow types the writer left in the file's | ||
| // metadata, where the executor reads what the footer alone states. | ||
| // | ||
| // A DATE arrives as datetime.date, where the executor reads midnight of that | ||
| // day; milliseconds reach the year 9999. Only an object column, since a | ||
| // timestamp's values are dates too. An 8- or 16-bit integer is widened to | ||
| // the INTEGER the executor reads, or 120 + 10 wraps to -126. | ||
| // | ||
| // Named in the nullable spelling, `Float32` and not `float32`, and widened | ||
| // into the nullable dtype in turn, so a hole stays a hole rather than | ||
| // becoming the NaN a numpy column would have to write it as. | ||
| val columns = | ||
| """|for _column, _values in out1df.items(): | ||
| | _first = next(iter(_values.dropna()), None) | ||
| | if isinstance(_first, Decimal): | ||
| | out1df[_column] = _values.astype("Float64") | ||
| | elif _values.dtype == object and isinstance(_first, date): | ||
| | out1df[_column] = _values.astype("datetime64[ms]") | ||
| | elif isinstance(_values.dtype, pd.DatetimeTZDtype): | ||
| | out1df[_column] = _values.dt.tz_convert("UTC").dt.tz_localize(None) | ||
| | elif _values.dtype.kind in "um": | ||
| | out1df[_column] = _values.astype("Int64") | ||
| | elif _values.dtype in ("Int8", "Int16"): | ||
| | out1df[_column] = _values.astype("Int32") | ||
| | elif _values.dtype == "Float32": | ||
|
kz930 marked this conversation as resolved.
|
||
| | out1df[_column] = _values.astype("Float64")""".stripMargin | ||
| // The executor drops `offset` rows and then takes `limit` of them. Parquet | ||
| // can skip whole row groups but not an arbitrary row range, so the same | ||
| // window is taken once the frame is in memory, as the Arrow source does. | ||
| // | ||
| // Clamped first: a plan from the API can carry a negative, which `iloc` | ||
| // counts from the end. | ||
| val window = (offset.map(_.max(0)), limit.map(_.max(0))) match { | ||
| // The end of the window is counted in Long: two Ints the operator accepts | ||
| // can add up past what an Int holds, and the slice would come out negative | ||
| // and take the wrong rows. | ||
| case (Some(o), Some(l)) => Some(s"$o:${o.toLong + l}") | ||
| case (Some(o), None) => Some(s"$o:") | ||
| case (None, Some(l)) => Some(s":$l") | ||
| case _ => None | ||
| } | ||
| (Seq(read, columns) ++ window.map(w => s"out1df = out1df.iloc[$w].reset_index(drop=True)")) | ||
| .mkString("\n") | ||
| } | ||
|
|
||
| @throws[IOException] | ||
| override def getPhysicalOp( | ||
| workflowId: WorkflowIdentity, | ||
| executionId: ExecutionIdentity | ||
| ): PhysicalOp = { | ||
| PhysicalOp | ||
| .sourcePhysicalOp( | ||
| workflowId, | ||
| executionId, | ||
| operatorIdentifier, | ||
| OpExecWithClassName( | ||
| "org.apache.texera.amber.operator.source.scan.parquet.ParquetScanSourceOpExec", | ||
| objectMapper.writeValueAsString(this) | ||
| ) | ||
| ) | ||
| .withInputPorts(operatorInfo.inputPorts) | ||
| .withOutputPorts(operatorInfo.outputPorts) | ||
| .withPropagateSchema( | ||
| SchemaPropagationFunc(_ => Map(operatorInfo.outputPorts.head.id -> inferSchema())) | ||
| ) | ||
| } | ||
|
|
||
| /** The file's own schema, read from its footer. No rows are read to get it. */ | ||
| @Override | ||
| def inferSchema(): Schema = { | ||
| require( | ||
| fileResolved(), | ||
| "No file selected. Please select a valid .parquet file from the 'File' dropdown in the right panel." | ||
| ) | ||
|
|
||
| val uri = new URI(fileName.get) | ||
| if (uri.getScheme == "file") { | ||
| require( | ||
| new java.io.File(uri).isFile, | ||
| "The selected item is a folder or does not exist. Please select an actual .parquet file from the 'File' dropdown." | ||
| ) | ||
| } | ||
| val file = DocumentFactory.openReadonlyDocument(uri).asFile() | ||
|
|
||
| Using(ParquetFileReader.open(new LocalInputFile(file.toPath))) { reader => | ||
| ParquetSchemaMapping.toTexeraSchema(reader.getFooter.getFileMetaData.getSchema) | ||
| }.recoverWith { | ||
| case e: UnsupportedOperationException => scala.util.Failure(e) | ||
| case scala.util.control.NonFatal(e) => | ||
| scala.util.Failure( | ||
| new RuntimeException( | ||
| "Failed to read the .parquet file. Please ensure it is a valid Parquet file.", | ||
| e | ||
| ) | ||
| ) | ||
| }.get | ||
| } | ||
| } | ||
184 changes: 184 additions & 0 deletions
184
.../scala/org/apache/texera/amber/operator/source/scan/parquet/ParquetScanSourceOpExec.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| /* | ||
| * 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.texera.amber.operator.source.scan.parquet | ||
|
|
||
| import org.apache.parquet.example.data.Group | ||
| import org.apache.parquet.example.data.simple.NanoTime | ||
| import org.apache.parquet.example.data.simple.convert.GroupRecordConverter | ||
| import org.apache.parquet.hadoop.ParquetFileReader | ||
| import org.apache.parquet.io.{ColumnIOFactory, LocalInputFile} | ||
| import org.apache.parquet.schema.LogicalTypeAnnotation.{ | ||
| DateLogicalTypeAnnotation, | ||
| DecimalLogicalTypeAnnotation, | ||
| IntLogicalTypeAnnotation, | ||
| JsonLogicalTypeAnnotation, | ||
| StringLogicalTypeAnnotation, | ||
| TimeUnit, | ||
| TimestampLogicalTypeAnnotation | ||
| } | ||
| import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName | ||
| import org.apache.parquet.schema.{MessageType, PrimitiveType} | ||
| import org.apache.texera.amber.core.executor.SourceOperatorExecutor | ||
| import org.apache.texera.amber.core.storage.DocumentFactory | ||
| import org.apache.texera.amber.core.tuple.TupleLike | ||
| import org.apache.texera.amber.util.JSONUtils.objectMapper | ||
|
|
||
| import java.net.URI | ||
| import java.sql.Timestamp | ||
| import java.time.{Instant, LocalDate, LocalDateTime, ZoneOffset} | ||
| import java.time.temporal.ChronoUnit | ||
| import scala.jdk.CollectionConverters._ | ||
|
|
||
| class ParquetScanSourceOpExec(descString: String) extends SourceOperatorExecutor { | ||
|
|
||
| /** The Julian day 1970-01-01 falls on, which an INT96 counts its days from. */ | ||
| private val UnixEpochJulianDay = 2440588L | ||
|
|
||
| private val desc: ParquetScanSourceOpDesc = | ||
| objectMapper.readValue(descString, classOf[ParquetScanSourceOpDesc]) | ||
| private var reader: Option[ParquetFileReader] = None | ||
|
|
||
| override def open(): Unit = { | ||
| val file = DocumentFactory.openReadonlyDocument(new URI(desc.fileName.get)).asFile() | ||
| reader = Some(ParquetFileReader.open(new LocalInputFile(file.toPath))) | ||
| } | ||
|
|
||
| override def produceTuple(): Iterator[TupleLike] = { | ||
| val fileReader = reader.get | ||
| val messageType = fileReader.getFooter.getFileMetaData.getSchema | ||
| val columns = messageType.getFields.asScala.toVector | ||
|
|
||
| // One row group at a time: the format stores rows in groups and a reader | ||
| // that asked for all of them at once would hold the whole file in memory, | ||
| // which is the thing a columnar format is chosen to avoid. | ||
| val rows: Iterator[TupleLike] = Iterator | ||
| .continually(fileReader.readNextRowGroup()) | ||
| .takeWhile(_ != null) | ||
| .flatMap { pages => | ||
| val recordReader = new ColumnIOFactory() | ||
| .getColumnIO(messageType) | ||
| .getRecordReader(pages, new GroupRecordConverter(messageType)) | ||
| (0L until pages.getRowCount).iterator.map { _ => | ||
| val group = recordReader.read() | ||
| TupleLike(columns.indices.map(i => readField(group, i, messageType)): _*) | ||
| } | ||
| } | ||
|
|
||
| val afterOffset = rows.drop(desc.offset.getOrElse(0)) | ||
| desc.limit.fold(afterOffset)(afterOffset.take) | ||
| } | ||
|
|
||
| /** One cell, as the Texera type [[ParquetSchemaMapping]] said the column is. */ | ||
| private def readField(group: Group, index: Int, messageType: MessageType): Any = { | ||
| // An optional column that was not written for this row repeats zero times. | ||
| // Parquet has no "null value": absence is the null. | ||
| if (group.getFieldRepetitionCount(index) == 0) return null | ||
| val primitive = messageType.getType(index).asPrimitiveType() | ||
| primitive.getLogicalTypeAnnotation match { | ||
| case annotation: DecimalLogicalTypeAnnotation => | ||
| decimalOf(group, index, primitive, annotation) | ||
| // An unsigned column counts up where its storage counts down, so the bits | ||
| // are read again as the number the file means: -1 in an unsigned 32-bit | ||
| // column is 4294967295, which is what pandas reads there too. The narrower | ||
| // widths are stored as themselves and the same reading leaves them alone. | ||
| case annotation: IntLogicalTypeAnnotation if !annotation.isSigned => | ||
| group.getInteger(index, 0).toLong & 0xffffffffL | ||
| case _ => primitiveOf(group, index, primitive) | ||
| } | ||
| } | ||
|
|
||
| /** The number a DECIMAL column stands for: its integer, with the point moved. */ | ||
| private def decimalOf( | ||
| group: Group, | ||
| index: Int, | ||
| primitive: PrimitiveType, | ||
| annotation: DecimalLogicalTypeAnnotation | ||
| ): Double = { | ||
| val unscaled = primitive.getPrimitiveTypeName match { | ||
| case PrimitiveTypeName.INT32 => BigInt(group.getInteger(index, 0)) | ||
| case PrimitiveTypeName.INT64 => BigInt(group.getLong(index, 0)) | ||
| // The wider ones store the integer as its big-endian two's complement. | ||
| case _ => BigInt(group.getBinary(index, 0).getBytes) | ||
| } | ||
| BigDecimal(unscaled, annotation.getScale).toDouble | ||
| } | ||
|
|
||
| /** One cell of a column the file states nothing more about than its storage. */ | ||
| private def primitiveOf(group: Group, index: Int, primitive: PrimitiveType): Any = { | ||
| primitive.getPrimitiveTypeName match { | ||
| case PrimitiveTypeName.BOOLEAN => group.getBoolean(index, 0) | ||
| case PrimitiveTypeName.FLOAT => group.getFloat(index, 0).toDouble | ||
| case PrimitiveTypeName.DOUBLE => group.getDouble(index, 0) | ||
| case PrimitiveTypeName.INT32 => | ||
| val raw = group.getInteger(index, 0) | ||
| primitive.getLogicalTypeAnnotation match { | ||
| // A DATE is a count of days, and Texera's nearest column is a moment. | ||
| // Midnight of that day, in the same UTC the file counts from. | ||
| case _: DateLogicalTypeAnnotation => | ||
| Timestamp.valueOf(LocalDate.ofEpochDay(raw.toLong).atStartOfDay) | ||
| case _ => raw | ||
| } | ||
| case PrimitiveTypeName.INT64 => | ||
| val raw = group.getLong(index, 0) | ||
| primitive.getLogicalTypeAnnotation match { | ||
| case annotation: TimestampLogicalTypeAnnotation => | ||
| // A Texera TIMESTAMP carries no zone, so the count from the epoch is | ||
| // read with UTC arithmetic and the wall clock it lands on is the | ||
| // whole of the value. `new Timestamp(millis)` would instead shift it | ||
| // by whatever zone the machine running the workflow is set to, and | ||
| // the exported script, which reads the same file with pandas, would | ||
| // disagree by exactly that offset. | ||
| Timestamp.valueOf( | ||
| LocalDateTime.ofInstant(instantOf(raw, annotation.getUnit), ZoneOffset.UTC) | ||
| ) | ||
| case _ => raw | ||
| } | ||
| case PrimitiveTypeName.BINARY => | ||
| primitive.getLogicalTypeAnnotation match { | ||
| case _: StringLogicalTypeAnnotation | _: JsonLogicalTypeAnnotation => | ||
| group.getBinary(index, 0).toStringUsingUTF8 | ||
| case _ => group.getBinary(index, 0).getBytes | ||
| } | ||
| // Twelve bytes holding a Julian day and the nanoseconds into it, which is | ||
| // how a timestamp was written before there was an annotation for one. The | ||
| // same UTC arithmetic as above: the wall clock it lands on is the value. | ||
| case PrimitiveTypeName.INT96 => | ||
| val nanoTime = NanoTime.fromBinary(group.getInt96(index, 0)) | ||
| val moment = Instant.EPOCH | ||
| .plus(nanoTime.getJulianDay - UnixEpochJulianDay, ChronoUnit.DAYS) | ||
| .plusNanos(nanoTime.getTimeOfDayNanos) | ||
| Timestamp.valueOf(LocalDateTime.ofInstant(moment, ZoneOffset.UTC)) | ||
| case PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY => group.getBinary(index, 0).getBytes | ||
| } | ||
| } | ||
|
|
||
| /** The moment a timestamp counts to, in whatever unit the file counts in. A | ||
| * `java.sql.Timestamp` holds nanoseconds, so a file written in micros keeps | ||
| * the digits under the millisecond that rounding would drop. | ||
| */ | ||
| private def instantOf(value: Long, unit: TimeUnit): Instant = | ||
| unit match { | ||
| case TimeUnit.MILLIS => Instant.EPOCH.plus(value, ChronoUnit.MILLIS) | ||
| case TimeUnit.MICROS => Instant.EPOCH.plus(value, ChronoUnit.MICROS) | ||
| case TimeUnit.NANOS => Instant.EPOCH.plusNanos(value) | ||
| } | ||
|
|
||
| override def close(): Unit = reader.foreach(_.close()) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.