andygrove commented on code in PR #6725:
URL: https://github.com/apache/datafusion-comet/pull/6725#discussion_r4241759688
##########
spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala:
##########
@@ -1130,49 +1162,43 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
// verbatim for iceberg-rust to decode. Unencrypted files
leave the field unset.
keyMetadataBytes(keyMetadataMethod,
dataFile).foreach(taskBuilder.setKeyMetadata)
- val taskSchema = taskSchemaMethod.invoke(task)
-
val deletes =
IcebergReflection.getDeleteFilesFromTask(task,
fileScanTaskClass)
val hasDeletes = !deletes.isEmpty
- val baseSchema: AnyRef =
+ // An equality delete may be keyed on a column dropped from
the current schema
+ // (schema evolution). iceberg-rust must read that column to
apply the delete,
+ // so union the equality-delete field ids into the task
schema, resolving any
+ // dropped ones from the table's schema history (mirrors
Iceberg-Java's
+ // DeleteFilter.fileProjection).
+ val equalityFieldIds =
if (hasDeletes) {
- // An equality delete may be keyed on a column dropped
from the current schema
- // (schema evolution). iceberg-rust must read that column
to apply the delete,
- // so union the equality-delete field ids into the task
schema, resolving any
- // dropped ones from the table's schema history (mirrors
Iceberg-Java's
- // DeleteFilter.fileProjection).
- val equalityFieldIds = deletes.asScala.flatMap { df =>
+ deletes.asScala.flatMap { df =>
requiredEqualityFieldIds(deleteFileClass,
df).asScala.map(_.intValue())
}.toSeq
- if (equalityFieldIds.nonEmpty) {
- IcebergReflection
- .schemaWithRequiredFields(taskSchema, metadata.table,
equalityFieldIds)
- .asInstanceOf[AnyRef]
- } else {
- taskSchema
- }
} else {
- if (hasHistoricalColumns) {
- metadata.scanSchema.asInstanceOf[AnyRef]
- } else {
- metadata.tableSchema.asInstanceOf[AnyRef]
- }
+ Nil
}
// iceberg-rust validates a FileScanTask by resolving its
partition spec against
// the task schema, so a task carrying a partition spec needs
that spec's source
// columns present even when the query projects them out (e.g.
selecting only
// _spec_id / _partition). Union them in. project_field_ids
still drives the read,
// so these columns are not materialized into the output.
- val schema: AnyRef =
- IcebergReflection
- .schemaWithRequiredFields(
- baseSchema,
- metadata.table,
- IcebergReflection.partitionSourceFieldIds(task,
fileScanTaskClass))
- .asInstanceOf[AnyRef]
+ val requiredFieldIds = equalityFieldIds ++
+ IcebergReflection.partitionSourceFieldIds(task,
fileScanTaskClass)
+
+ val baseSchema: AnyRef =
+ if (canUsePrunedSchema(requiredFieldIds)) {
Review Comment:
There's one more constraint on the task schemas. iceberg-rust shares
equality deletes across the tasks of a partition. Comet builds one
`ArrowReader` per partition, and its `DeleteFilter` builds each equality-delete
predicate once per delete file, with the column names of the task that loads it
first. Every other task then binds that predicate by name against its own
schema (`build_equality_delete_predicate` in `delete_filter.rs`). So every task
of a scan needs to give a key the same name. Two things here break that. I
turned off `read.split.adaptive-size.enabled` in both repros so the tasks share
one reader.
The first is that the appended names now depend on what else a task needs. I
tried an unpartitioned table `t(id, x0, y)` with two data files. An equality
delete on `y = 200` covers only the second file. Then I dropped `y`, renamed
`x0` to `x`, and committed an equality delete on `x` in `(10, 25)` that covers
both files. The first task appends `x` from the current schema. The second also
needs `y`, so it takes both fields from the oldest schema, where `x` is still
`x0`. `SELECT id FROM t ORDER BY id` fails with `Field x not found in schema`
with pruning on. Spark returns `3`, and Comet matches it with pruning off.
The second is the base schema in a time travel read. A task whose nested
partition source the query prunes keeps `task.schema()`, with the current
names, while the other tasks use the snapshot's names. I tried a table
partitioned by `s.region` that then moves to an unpartitioned spec, so one
global equality delete on `k = 10` covers files from both specs, and renamed
`k` after the snapshot. `SELECT id, k FROM t VERSION AS OF <s>` fails with
`Field k not found in schema`. When `k` and `j` swap names instead, it either
fails with `Field j not found in schema` or returns `[1,100] [4,30]` where
Spark returns `[2,30] [4,30]`, depending on which task loads the delete first.
In that last case a row the delete removes comes back and a live row
disappears. With pruning off, every task with deletes reads `task.schema()`.
That gives the #6822 wrong values but deletes the right rows.
What do you think about resolving each missing field id to one name for the
whole scan, rather than per task? It could start from the scan's own schema,
which is the snapshot's schema for `VERSION AS OF` and the current one
otherwise, and use the history only for ids that schema lacks. Using that
schema as the full-schema fallback too would cover the time travel case, and
#6822's full-schema half with it. Doing this during planning would also let a
case it can't resolve fall back to Spark instead of failing the query. Could
the tests cover both cases, with adaptive split sizing off so the tasks share a
reader?
##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala:
##########
@@ -741,20 +743,30 @@ object IcebergReflection extends Logging {
logDebug(
s"Native Iceberg scan schema is missing field id(s)
${missingIds.mkString(",")}; " +
"resolving them from table schema history")
- val history = getAllSchemas(table)
- val resolvedFields = missingIds.map { id =>
- history.iterator
- .flatMap(s => findFieldObject(s, id))
- .toSeq
- .headOption
- .getOrElse(throw new IllegalStateException(
- s"Cannot resolve field id $id in table schema history"))
- }
val existing =
getMethod(baseSchema.getClass, "columns")
.invoke(baseSchema)
.asInstanceOf[java.util.List[_]]
- val newColumns = new java.util.ArrayList[Any](existing)
+ val existingNames = existing.asScala.map(fieldName).toSet
+ // Taking every field from one schema keeps their names apart, which a
name picked per field
+ // cannot promise once columns have been renamed into each other's
names. A VERSION AS OF
+ // scan schema carries the snapshot's names, so a schema also must not
name a field like a
+ // column `baseSchema` has. The current schema comes first, to keep
current names, and then
+ // the others newest first, so that a promoted column keeps its widest
type.
+ val schemas = getMethod(table.getClass, "schema").invoke(table) +:
+ getAllSchemas(table).reverse
+ val resolvedFields = schemas.iterator
+ .map(schema => missingIds.flatMap(findFieldObject(schema, _)))
+ .find { fields =>
+ val names = fields.map(fieldName)
+ fields.length == missingIds.length && names.distinct.length ==
names.length &&
Review Comment:
I can confirm this one through Comet at d88698c442. With your table, `SELECT
id FROM t ORDER BY id` throws `Cannot resolve field ids 2,3 from one schema in
the table schema history under names the task schema does not use` with pruning
on. Spark returns `2, 3`. With pruning off it matches Spark, because the base
schema already has `q` and only `p` needs appending. My comment on
`CometIcebergNativeScan.scala` is related, since whatever replaces the
one-schema rule also has to give each key the same name in every task.
--
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]