From ff55328c8e2f6ee0ffedc171ec944eeb16983121 Mon Sep 17 00:00:00 2001 From: yangshangqing95 Date: Thu, 16 Jul 2026 10:20:55 -0400 Subject: [PATCH] Core: Add all puffin files metadata table --- .../apache/iceberg/AllPuffinFilesTable.java | 906 ++++++++++++++++++ .../org/apache/iceberg/MetadataTableType.java | 3 +- .../apache/iceberg/MetadataTableUtils.java | 2 + .../iceberg/TestAllPuffinFilesTable.java | 863 +++++++++++++++++ .../hadoop/TestTableSerialization.java | 15 + .../iceberg/rest/TestRESTScanPlanning.java | 2 + 6 files changed, 1790 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/apache/iceberg/AllPuffinFilesTable.java create mode 100644 core/src/test/java/org/apache/iceberg/TestAllPuffinFilesTable.java diff --git a/core/src/main/java/org/apache/iceberg/AllPuffinFilesTable.java b/core/src/main/java/org/apache/iceberg/AllPuffinFilesTable.java new file mode 100644 index 000000000000..87e8779e66a2 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/AllPuffinFilesTable.java @@ -0,0 +1,906 @@ +/* + * 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.iceberg; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.IntPredicate; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.expressions.Binder; +import org.apache.iceberg.expressions.Bound; +import org.apache.iceberg.expressions.BoundReference; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionVisitors; +import org.apache.iceberg.expressions.ExpressionVisitors.BoundExpressionVisitor; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.puffin.StandardBlobTypes; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.StructProjection; + +/** + * A {@link Table} implementation that exposes Puffin files associated with all snapshots currently + * tracked by a table. + * + *

Each row represents a Puffin file referenced by one snapshot through one metadata source. The + * same physical Puffin file may appear in multiple rows when it is referenced by multiple snapshots + * or through multiple sources. + * + *

Deletion vector files are reconstructed from live delete-manifest entries for each reference + * snapshot. Statistics files come from the statistics files registered in the current table + * metadata. Statistics registration can change independently of snapshots, so this table does not + * reconstruct historical statistics registration state. + */ +public class AllPuffinFilesTable extends BaseMetadataTable { + + private static final String SOURCE_STATISTICS = "statistics"; + private static final String SOURCE_DELETION_VECTOR = "deletion_vector"; + + private static final int MIN_FORMAT_VERSION_DELETION_VECTORS = 3; + + private static final List DV_REFERENCED_BLOB_TYPES = + ImmutableList.of(StandardBlobTypes.DV_V1); + + private static final List DV_REFERENCED_FIELD_IDS = + ImmutableList.of(MetadataColumns.ROW_POSITION.fieldId()); + + private static final List DV_SCAN_COLUMNS = + ImmutableList.of( + DataFile.FILE_PATH.name(), + DataFile.FILE_FORMAT.name(), + DataFile.FILE_SIZE.name(), + DataFile.CONTENT_OFFSET.name(), + DataFile.CONTENT_SIZE.name()); + + private static final Types.NestedField REFERENCE_SNAPSHOT_ID = + Types.NestedField.required( + 1, + "reference_snapshot_id", + Types.LongType.get(), + "ID of the snapshot that references the Puffin file"); + + private static final Types.NestedField SOURCE = + Types.NestedField.required( + 3, + "source", + Types.StringType.get(), + "Metadata source that associates the Puffin file with the reference snapshot"); + + private static final Schema ALL_PUFFIN_FILES_SCHEMA = + new Schema( + REFERENCE_SNAPSHOT_ID, + Types.NestedField.required( + 2, + "file_path", + Types.StringType.get(), + "Fully qualified location of the Puffin file"), + SOURCE, + Types.NestedField.required( + 4, + "file_size_in_bytes", + Types.LongType.get(), + "Total size of the Puffin file in bytes"), + Types.NestedField.required( + 5, + "referenced_blob_count", + Types.IntegerType.get(), + "Number of referenced Puffin blobs represented by this row"), + Types.NestedField.required( + 6, + "referenced_blob_types", + Types.ListType.ofRequired(7, Types.StringType.get()), + "Distinct blob types across the referenced blobs"), + Types.NestedField.required( + 8, + "referenced_fields", + Types.ListType.ofRequired( + 9, + Types.StructType.of( + Types.NestedField.required( + 10, + "field_id", + Types.IntegerType.get(), + "Field ID referenced by at least one blob"), + Types.NestedField.optional( + 11, + "current_field_name", + Types.StringType.get(), + "Name currently assigned to the field ID; null if no longer present"))), + "Distinct fields referenced across the blobs")); + + AllPuffinFilesTable(Table table) { + this(table, table.name() + ".all_puffin_files"); + } + + AllPuffinFilesTable(Table table, String name) { + super(table, name); + } + + @Override + public TableScan newScan() { + return new AllPuffinFilesTableScan(table(), schema()); + } + + @Override + public Schema schema() { + return ALL_PUFFIN_FILES_SCHEMA; + } + + @Override + MetadataTableType metadataTableType() { + return MetadataTableType.ALL_PUFFIN_FILES; + } + + private static Map> statisticsRowsBySnapshotId( + List statisticsFiles, Schema tableSchema) { + Map> rowsBySnapshotId = Maps.newHashMap(); + + for (StatisticsFile statisticsFile : statisticsFiles) { + long referenceSnapshotId = statisticsFile.snapshotId(); + StaticDataTask.Row row = + puffinFileToRow(tableSchema, PuffinFileRow.fromStatisticsFile(statisticsFile)); + + rowsBySnapshotId + .computeIfAbsent(referenceSnapshotId, ignored -> Lists.newArrayList()) + .add(row); + } + + return rowsBySnapshotId; + } + + private static boolean mayHaveDeleteFiles(Snapshot snapshot) { + Map summary = snapshot.summary(); + if (summary == null) { + return true; + } + + String totalDeleteFiles = summary.get(SnapshotSummary.TOTAL_DELETE_FILES_PROP); + try { + return totalDeleteFiles == null || Long.parseLong(totalDeleteFiles) > 0L; + } catch (NumberFormatException ignored) { + return true; + } + } + + private static List scanDeletionVectorPuffinFiles( + FileIO io, + Map specsById, + long referenceSnapshotId, + ManifestListFile manifestList) { + Map dvFiles = Maps.newLinkedHashMap(); + + try (CloseableIterable manifests = readManifestList(io, manifestList)) { + for (ManifestFile manifest : manifests) { + if (manifest.content() == ManifestContent.DELETES) { + accumulateDeletionVectorsFromManifest( + io, specsById, referenceSnapshotId, manifest, dvFiles); + } + } + + } catch (IOException e) { + throw new RuntimeIOException( + e, + "Failed to read manifest list for snapshot %s: %s", + referenceSnapshotId, + manifestList.location()); + } + + return dvFiles.values().stream() + .map(DeletionVectorFileAccumulator::toPuffinFileRow) + .collect(ImmutableList.toImmutableList()); + } + + private static CloseableIterable readManifestList( + FileIO io, ManifestListFile manifestList) { + return InternalData.read(FileFormat.AVRO, io.newInputFile(manifestList)) + .setRootType(GenericManifestFile.class) + .setCustomType( + ManifestFile.PARTITION_SUMMARIES_ELEMENT_ID, GenericPartitionFieldSummary.class) + .project(ManifestFile.schema()) + .build(); + } + + private static void accumulateDeletionVectorsFromManifest( + FileIO io, + Map specsById, + long referenceSnapshotId, + ManifestFile deleteManifest, + Map dvFiles) { + try (ManifestReader reader = + ManifestFiles.readDeleteManifest(deleteManifest, io, specsById) + .select(DV_SCAN_COLUMNS); + CloseableIterable> entries = reader.liveEntries()) { + + for (ManifestEntry entry : entries) { + DeleteFile deleteFile = entry.file(); + if (!ContentFileUtil.isDV(deleteFile)) { + continue; + } + + String path = deleteFile.location(); + Long contentOffset = deleteFile.contentOffset(); + Long contentSizeInBytes = deleteFile.contentSizeInBytes(); + + Preconditions.checkState( + contentOffset != null, + "Missing content offset for deletion vector: snapshot=%s, manifest=%s, path=%s", + referenceSnapshotId, + deleteManifest.path(), + path); + Preconditions.checkState( + contentSizeInBytes != null, + "Missing content size for deletion vector: snapshot=%s, manifest=%s, path=%s", + referenceSnapshotId, + deleteManifest.path(), + path); + + long fileSizeInBytes = deleteFile.fileSizeInBytes(); + + dvFiles + .computeIfAbsent( + path, + ignored -> + new DeletionVectorFileAccumulator(referenceSnapshotId, path, fileSizeInBytes)) + .add(fileSizeInBytes, contentOffset, contentSizeInBytes); + } + + } catch (IOException e) { + throw new RuntimeIOException( + e, + "Failed to read delete manifest for snapshot %s: %s", + referenceSnapshotId, + deleteManifest.path()); + } + } + + private static StaticDataTask.Row puffinFileToRow(Schema tableSchema, PuffinFileRow puffinFile) { + ImmutableList.Builder referencedFields = + ImmutableList.builderWithExpectedSize(puffinFile.referencedFieldIds().size()); + + for (Integer fieldId : puffinFile.referencedFieldIds()) { + referencedFields.add(StaticDataTask.Row.of(fieldId, currentFieldName(tableSchema, fieldId))); + } + + return StaticDataTask.Row.of( + puffinFile.referenceSnapshotId(), + puffinFile.path(), + puffinFile.source(), + puffinFile.fileSizeInBytes(), + puffinFile.referencedBlobCount(), + puffinFile.referencedBlobTypes(), + referencedFields.build()); + } + + private static String currentFieldName(Schema tableSchema, int fieldId) { + if (fieldId == MetadataColumns.ROW_POSITION.fieldId()) { + return MetadataColumns.ROW_POSITION.name(); + } + + return tableSchema.findColumnName(fieldId); + } + + private static long estimatedDvTaskSize(Snapshot snapshot) { + long deleteFileCount = totalDeleteFileCount(snapshot); + + if (deleteFileCount <= 1_000L) { + return 4L * 1024 * 1024; + } else if (deleteFileCount <= 10_000L) { + return 8L * 1024 * 1024; + } else if (deleteFileCount <= 50_000L) { + return 16L * 1024 * 1024; + } else { + return 32L * 1024 * 1024; + } + } + + private static long totalDeleteFileCount(Snapshot snapshot) { + Map summary = snapshot.summary(); + if (summary == null) { + return 1L; + } + + String value = summary.get(SnapshotSummary.TOTAL_DELETE_FILES_PROP); + if (value == null) { + return 1L; + } + + try { + return Math.max(1L, Long.parseLong(value)); + } catch (NumberFormatException ignored) { + return 1L; + } + } + + private static class AllPuffinFilesTableScan extends BaseAllMetadataTableScan { + + AllPuffinFilesTableScan(Table table, Schema schema) { + super(table, schema, MetadataTableType.ALL_PUFFIN_FILES); + } + + private AllPuffinFilesTableScan(Table table, Schema schema, TableScanContext context) { + super(table, schema, MetadataTableType.ALL_PUFFIN_FILES, context); + } + + @Override + protected TableScan newRefinedScan(Table table, Schema schema, TableScanContext context) { + return new AllPuffinFilesTableScan(table, schema, context); + } + + @Override + protected CloseableIterable doPlanFiles() { + BaseTable baseTable = (BaseTable) table(); + TableMetadata metadata = baseTable.operations().current(); + Schema tableSchema = metadata.schema(); + Schema projectedSchema = schema(); + + Expression planningFilter = filter(); + Expression residual = residualFilter(); + SnapshotReferenceEvaluator taskEvaluator = + new SnapshotReferenceEvaluator( + planningFilter, + ALL_PUFFIN_FILES_SCHEMA.asStruct(), + REFERENCE_SNAPSHOT_ID.fieldId(), + SOURCE.fieldId(), + isCaseSensitive()); + + boolean mayScanStatistics = taskEvaluator.mayMatchSource(SOURCE_STATISTICS); + boolean mayScanDeletionVectors = + metadata.formatVersion() >= MIN_FORMAT_VERSION_DELETION_VECTORS + && taskEvaluator.mayMatchSource(SOURCE_DELETION_VECTOR); + + Map> statisticsRowsBySnapshotId = + mayScanStatistics + ? AllPuffinFilesTable.statisticsRowsBySnapshotId( + metadata.statisticsFiles(), tableSchema) + : ImmutableMap.of(); + Map specsById = + mayScanDeletionVectors ? ImmutableMap.copyOf(metadata.specsById()) : ImmutableMap.of(); + + String location = + metadata.metadataFileLocation() != null + ? metadata.metadataFileLocation() + : table().location(); + ImmutableList.Builder tasks = ImmutableList.builder(); + + for (Snapshot snapshot : metadata.snapshots()) { + addStatisticsTask( + tasks, + baseTable, + projectedSchema, + location, + snapshot, + mayScanStatistics, + taskEvaluator, + statisticsRowsBySnapshotId); + + addDeletionVectorTask( + tasks, + baseTable, + tableSchema, + projectedSchema, + specsById, + residual, + snapshot, + mayScanDeletionVectors, + taskEvaluator); + } + return CloseableIterable.withNoopClose(tasks.build()); + } + + private static void addStatisticsTask( + ImmutableList.Builder tasks, + BaseTable baseTable, + Schema projectedSchema, + String location, + Snapshot snapshot, + boolean mayScanStatistics, + SnapshotReferenceEvaluator taskEvaluator, + Map> statisticsRowsBySnapshotId) { + + if (!mayScanStatistics || !taskEvaluator.mayMatch(snapshot, SOURCE_STATISTICS)) { + return; + } + + List statisticsRows = + statisticsRowsBySnapshotId.getOrDefault(snapshot.snapshotId(), ImmutableList.of()); + + if (!statisticsRows.isEmpty()) { + tasks.add( + StaticDataTask.of( + baseTable.io().newInputFile(location), + ALL_PUFFIN_FILES_SCHEMA, + projectedSchema, + statisticsRows, + row -> row)); + } + } + + private static void addDeletionVectorTask( + ImmutableList.Builder tasks, + BaseTable baseTable, + Schema tableSchema, + Schema projectedSchema, + Map specsById, + Expression residual, + Snapshot snapshot, + boolean mayScanDeletionVectors, + SnapshotReferenceEvaluator taskEvaluator) { + + String manifestListLocation = snapshot.manifestListLocation(); + boolean shouldScanDeletionVectors = + mayScanDeletionVectors + && manifestListLocation != null + && mayHaveDeleteFiles(snapshot) + && taskEvaluator.mayMatch(snapshot, SOURCE_DELETION_VECTOR); + + if (!shouldScanDeletionVectors) { + return; + } + + tasks.add( + new DeletionVectorPuffinFilesTask( + baseTable.io(), + tableSchema, + projectedSchema, + specsById, + residual, + snapshot.snapshotId(), + new BaseManifestListFile(manifestListLocation, snapshot.keyId()), + estimatedDvTaskSize(snapshot))); + } + } + + /** + * Conservatively evaluates a metadata-table filter using the snapshot ID and Puffin metadata + * source values that are known during task planning. + * + *

The evaluator makes exact decisions only for predicates over supported fields whose values + * are known in the current evaluation context. Predicates over unknown values or unsupported + * fields return {@link MatchResult#MIGHT_MATCH}. The three-state result is important for NOT: + * negating an unknown predicate is still unknown and must not prune a task. + */ + private static class SnapshotReferenceEvaluator { + + private final int referenceSnapshotIdFieldId; + private final int sourceFieldId; + private final Expression boundExpression; + + private SnapshotReferenceEvaluator( + Expression expression, + Types.StructType structType, + int referenceSnapshotIdFieldId, + int sourceFieldId, + boolean caseSensitive) { + this.referenceSnapshotIdFieldId = referenceSnapshotIdFieldId; + this.sourceFieldId = sourceFieldId; + this.boundExpression = Binder.bind(structType, expression, caseSensitive); + } + + private boolean mayMatch(Snapshot snapshot, String source) { + return new SnapshotEvalVisitor(snapshot.snapshotId(), source).eval() + != MatchResult.CANNOT_MATCH; + } + + private boolean mayMatchSource(String source) { + return new SnapshotEvalVisitor(source).eval() != MatchResult.CANNOT_MATCH; + } + + private enum MatchResult { + CANNOT_MATCH, + MIGHT_MATCH, + MUST_MATCH; + + private static MatchResult from(boolean matches) { + return matches ? MUST_MATCH : CANNOT_MATCH; + } + + private MatchResult negate() { + return switch (this) { + case CANNOT_MATCH -> MUST_MATCH; + case MUST_MATCH -> CANNOT_MATCH; + case MIGHT_MATCH -> MIGHT_MATCH; + }; + } + + private static MatchResult and(MatchResult left, MatchResult right) { + if (left == CANNOT_MATCH || right == CANNOT_MATCH) { + return CANNOT_MATCH; + } else if (left == MUST_MATCH && right == MUST_MATCH) { + return MUST_MATCH; + } + + return MIGHT_MATCH; + } + + private static MatchResult or(MatchResult left, MatchResult right) { + if (left == MUST_MATCH || right == MUST_MATCH) { + return MUST_MATCH; + } else if (left == CANNOT_MATCH && right == CANNOT_MATCH) { + return CANNOT_MATCH; + } + + return MIGHT_MATCH; + } + } + + private class SnapshotEvalVisitor extends BoundExpressionVisitor { + + private final boolean snapshotIdKnown; + private final long snapshotId; + private final String source; + + private SnapshotEvalVisitor(long snapshotId, String source) { + this.snapshotIdKnown = true; + this.snapshotId = snapshotId; + this.source = source; + } + + private SnapshotEvalVisitor(String source) { + this.snapshotIdKnown = false; + this.snapshotId = 0L; + this.source = source; + } + + private MatchResult eval() { + return ExpressionVisitors.visit(boundExpression, this); + } + + @Override + public MatchResult alwaysTrue() { + return MatchResult.MUST_MATCH; + } + + @Override + public MatchResult alwaysFalse() { + return MatchResult.CANNOT_MATCH; + } + + @Override + public MatchResult not(MatchResult result) { + return result.negate(); + } + + @Override + public MatchResult and(MatchResult leftResult, MatchResult rightResult) { + return MatchResult.and(leftResult, rightResult); + } + + @Override + public MatchResult or(MatchResult leftResult, MatchResult rightResult) { + return MatchResult.or(leftResult, rightResult); + } + + @Override + public MatchResult isNull(BoundReference reference) { + return isSupportedReference(reference) ? MatchResult.CANNOT_MATCH : MatchResult.MIGHT_MATCH; + } + + @Override + public MatchResult notNull(BoundReference reference) { + return isSupportedReference(reference) ? MatchResult.MUST_MATCH : MatchResult.MIGHT_MATCH; + } + + @Override + public MatchResult isNaN(BoundReference reference) { + return MatchResult.MIGHT_MATCH; + } + + @Override + public MatchResult notNaN(BoundReference reference) { + return MatchResult.MIGHT_MATCH; + } + + @Override + public MatchResult lt(BoundReference reference, Literal literal) { + return evaluateComparison(reference, literal, compareResult -> compareResult < 0); + } + + @Override + public MatchResult ltEq(BoundReference reference, Literal literal) { + return evaluateComparison(reference, literal, compareResult -> compareResult <= 0); + } + + @Override + public MatchResult gt(BoundReference reference, Literal literal) { + return evaluateComparison(reference, literal, compareResult -> compareResult > 0); + } + + @Override + public MatchResult gtEq(BoundReference reference, Literal literal) { + return evaluateComparison(reference, literal, compareResult -> compareResult >= 0); + } + + @Override + public MatchResult eq(BoundReference reference, Literal literal) { + return evaluateComparison(reference, literal, compareResult -> compareResult == 0); + } + + @Override + public MatchResult notEq(BoundReference reference, Literal literal) { + return eq(reference, literal).negate(); + } + + @Override + public MatchResult in(BoundReference reference, Set literalSet) { + if (isReferenceSnapshotId(reference)) { + if (!snapshotIdKnown) { + return MatchResult.MIGHT_MATCH; + } + + return MatchResult.from(literalSet.contains(snapshotId)); + } else if (isSource(reference)) { + return MatchResult.from(literalSet.contains(source)); + } + + return MatchResult.MIGHT_MATCH; + } + + @Override + public MatchResult notIn(BoundReference reference, Set literalSet) { + return in(reference, literalSet).negate(); + } + + @Override + public MatchResult startsWith(BoundReference reference, Literal literal) { + if (!isSource(reference)) { + return MatchResult.MIGHT_MATCH; + } + + Literal stringLiteral = literal.to(Types.StringType.get()); + Preconditions.checkState( + stringLiteral != null, "Cannot convert literal to string for source: %s", literal); + return MatchResult.from(source.startsWith(stringLiteral.value().toString())); + } + + @Override + public MatchResult notStartsWith(BoundReference reference, Literal literal) { + return startsWith(reference, literal).negate(); + } + + @Override + public MatchResult handleNonReference(Bound term) { + return MatchResult.MIGHT_MATCH; + } + + private MatchResult evaluateComparison( + BoundReference reference, Literal literal, IntPredicate matches) { + int compareResult; + if (isReferenceSnapshotId(reference)) { + if (!snapshotIdKnown) { + return MatchResult.MIGHT_MATCH; + } + + Literal longLiteral = literal.to(Types.LongType.get()); + Preconditions.checkState( + longLiteral != null, + "Cannot convert literal to long for reference_snapshot_id: %s", + literal); + compareResult = longLiteral.comparator().compare(snapshotId, longLiteral.value()); + + } else if (isSource(reference)) { + Literal stringLiteral = literal.to(Types.StringType.get()); + Preconditions.checkState( + stringLiteral != null, "Cannot convert literal to string for source: %s", literal); + compareResult = stringLiteral.comparator().compare(source, stringLiteral.value()); + + } else { + return MatchResult.MIGHT_MATCH; + } + + return MatchResult.from(matches.test(compareResult)); + } + + private boolean isSupportedReference(BoundReference reference) { + return isReferenceSnapshotId(reference) || isSource(reference); + } + + private boolean isReferenceSnapshotId(BoundReference reference) { + return reference.fieldId() == referenceSnapshotIdFieldId; + } + + private boolean isSource(BoundReference reference) { + return reference.fieldId() == sourceFieldId; + } + } + } + + private static class DeletionVectorPuffinFilesTask implements DataTask { + + private final FileIO io; + private final Schema tableSchema; + private final Schema projectedSchema; + private final Map specsById; + private final Expression residual; + private final long referenceSnapshotId; + private final ManifestListFile manifestList; + private final long estimatedTaskSize; + + private DataFile lazyDataFile = null; + + private DeletionVectorPuffinFilesTask( + FileIO io, + Schema tableSchema, + Schema projectedSchema, + Map specsById, + Expression residual, + long referenceSnapshotId, + ManifestListFile manifestList, + long estimatedTaskSize) { + this.io = io; + this.tableSchema = tableSchema; + this.projectedSchema = projectedSchema; + this.specsById = specsById; + this.residual = residual; + this.referenceSnapshotId = referenceSnapshotId; + this.manifestList = manifestList; + this.estimatedTaskSize = estimatedTaskSize; + } + + @Override + public List deletes() { + return ImmutableList.of(); + } + + @Override + public CloseableIterable rows() { + List puffinFiles = + scanDeletionVectorPuffinFiles(io, specsById, referenceSnapshotId, manifestList); + + CloseableIterable rows = + CloseableIterable.transform( + CloseableIterable.withNoopClose(puffinFiles), + puffinFile -> puffinFileToRow(tableSchema, puffinFile)); + + StructProjection projection = + StructProjection.create(ALL_PUFFIN_FILES_SCHEMA, projectedSchema); + + return CloseableIterable.transform(rows, projection::wrap); + } + + @Override + public DataFile file() { + if (lazyDataFile == null) { + // The actual result row count is unknown until the manifests are scanned. + this.lazyDataFile = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withInputFile(io.newInputFile(manifestList)) + .withFormat(FileFormat.AVRO) + .withRecordCount(1L) + .build(); + } + + return lazyDataFile; + } + + @Override + public PartitionSpec spec() { + return PartitionSpec.unpartitioned(); + } + + @Override + public long start() { + return 0L; + } + + @Override + public long length() { + return estimatedTaskSize; + } + + @Override + public Expression residual() { + return residual; + } + + @Override + public Iterable split(long splitSize) { + return ImmutableList.of(this); + } + + @Override + public Schema schema() { + return projectedSchema; + } + } + + private record PuffinFileRow( + long referenceSnapshotId, + String path, + String source, + long fileSizeInBytes, + int referencedBlobCount, + List referencedBlobTypes, + List referencedFieldIds) { + + private static PuffinFileRow fromStatisticsFile(StatisticsFile statisticsFile) { + List blobs = statisticsFile.blobMetadata(); + + List referencedBlobTypes = + blobs.stream() + .map(BlobMetadata::type) + .distinct() + .collect(ImmutableList.toImmutableList()); + + List referencedFieldIds = + blobs.stream() + .flatMap(blob -> blob.fields().stream()) + .distinct() + .collect(ImmutableList.toImmutableList()); + + return new PuffinFileRow( + statisticsFile.snapshotId(), + statisticsFile.path(), + SOURCE_STATISTICS, + statisticsFile.fileSizeInBytes(), + blobs.size(), + referencedBlobTypes, + referencedFieldIds); + } + } + + private static class DeletionVectorFileAccumulator { + + private final long referenceSnapshotId; + private final String path; + private final long fileSizeInBytes; + private final Set referencedBlobs = Sets.newLinkedHashSet(); + + private DeletionVectorFileAccumulator( + long referenceSnapshotId, String path, long fileSizeInBytes) { + this.referenceSnapshotId = referenceSnapshotId; + this.path = path; + this.fileSizeInBytes = fileSizeInBytes; + } + + private void add(long entryFileSizeInBytes, long contentOffset, long contentSizeInBytes) { + Preconditions.checkState( + fileSizeInBytes == entryFileSizeInBytes, + "Deletion vectors in the same Puffin file have different file sizes: " + + "path=%s, referenceSnapshotId=%s, expected=%s, actual=%s", + path, + referenceSnapshotId, + fileSizeInBytes, + entryFileSizeInBytes); + referencedBlobs.add(new DeletionVectorBlobKey(contentOffset, contentSizeInBytes)); + } + + private PuffinFileRow toPuffinFileRow() { + return new PuffinFileRow( + referenceSnapshotId, + path, + SOURCE_DELETION_VECTOR, + fileSizeInBytes, + referencedBlobs.size(), + DV_REFERENCED_BLOB_TYPES, + DV_REFERENCED_FIELD_IDS); + } + } + + private record DeletionVectorBlobKey(long contentOffset, long contentSizeInBytes) {} +} diff --git a/core/src/main/java/org/apache/iceberg/MetadataTableType.java b/core/src/main/java/org/apache/iceberg/MetadataTableType.java index 733a11d900f1..f7994faa4d4a 100644 --- a/core/src/main/java/org/apache/iceberg/MetadataTableType.java +++ b/core/src/main/java/org/apache/iceberg/MetadataTableType.java @@ -36,7 +36,8 @@ public enum MetadataTableType { ALL_FILES, ALL_MANIFESTS, ALL_ENTRIES, - POSITION_DELETES; + POSITION_DELETES, + ALL_PUFFIN_FILES; public static MetadataTableType from(String name) { try { diff --git a/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java b/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java index adb0f18ba1ad..10290bbe2bd1 100644 --- a/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java +++ b/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java @@ -88,6 +88,8 @@ private static Table createMetadataTableInstance( return new AllEntriesTable(baseTable, metadataTableName); case POSITION_DELETES: return new PositionDeletesTable(baseTable, metadataTableName); + case ALL_PUFFIN_FILES: + return new AllPuffinFilesTable(baseTable, metadataTableName); default: throw new NoSuchTableException( "Unknown metadata table type: %s for %s", type, metadataTableName); diff --git a/core/src/test/java/org/apache/iceberg/TestAllPuffinFilesTable.java b/core/src/test/java/org/apache/iceberg/TestAllPuffinFilesTable.java new file mode 100644 index 000000000000..383f46457cda --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestAllPuffinFilesTable.java @@ -0,0 +1,863 @@ +/* + * 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.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.puffin.StandardBlobTypes; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestAllPuffinFilesTable extends TestBase { + + private static final String SOURCE_STATISTICS = "statistics"; + private static final String SOURCE_DELETION_VECTOR = "deletion_vector"; + + @TestTemplate + public void testSchema() { + Schema expected = + new Schema( + Types.NestedField.required( + 1, + "reference_snapshot_id", + Types.LongType.get(), + "ID of the snapshot that references the Puffin file"), + Types.NestedField.required( + 2, + "file_path", + Types.StringType.get(), + "Fully qualified location of the Puffin file"), + Types.NestedField.required( + 3, + "source", + Types.StringType.get(), + "Metadata source that associates the Puffin file with the reference snapshot"), + Types.NestedField.required( + 4, + "file_size_in_bytes", + Types.LongType.get(), + "Total size of the Puffin file in bytes"), + Types.NestedField.required( + 5, + "referenced_blob_count", + Types.IntegerType.get(), + "Number of referenced Puffin blobs represented by this row"), + Types.NestedField.required( + 6, + "referenced_blob_types", + Types.ListType.ofRequired(7, Types.StringType.get()), + "Distinct blob types across the referenced blobs"), + Types.NestedField.required( + 8, + "referenced_fields", + Types.ListType.ofRequired( + 9, + Types.StructType.of( + Types.NestedField.required( + 10, + "field_id", + Types.IntegerType.get(), + "Field ID referenced by at least one blob"), + Types.NestedField.optional( + 11, + "current_field_name", + Types.StringType.get(), + "Name currently assigned to the field ID; null if no longer present"))), + "Distinct fields referenced across the blobs")); + + assertThat(metadataTable().schema().sameSchema(expected)).isTrue(); + } + + @TestTemplate + public void testEmptyTable() throws IOException { + assertThat(rawTaskRows(metadataTable().newScan())).isEmpty(); + } + + @TestTemplate + public void testStatisticsPuffinFile() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot snapshot = table.currentSnapshot(); + int idFieldId = fieldId("id"); + int dataFieldId = fieldId("data"); + + StatisticsFile statisticsFile = + statisticsFile( + snapshot, + "/path/to/statistics.puffin", + 100L, + blob(snapshot, "test-blob-v1", ImmutableList.of(idFieldId, dataFieldId))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + assertThat(rawTaskRows(metadataTable().newScan())) + .containsExactly( + new PuffinRow( + snapshot.snapshotId(), + statisticsFile.path(), + SOURCE_STATISTICS, + statisticsFile.fileSizeInBytes(), + 1, + ImmutableList.of("test-blob-v1"), + ImmutableList.of( + new ReferencedField(idFieldId, "id"), + new ReferencedField(dataFieldId, "data")))); + } + + @TestTemplate + public void testStatisticsBlobAggregation() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot snapshot = table.currentSnapshot(); + int idFieldId = fieldId("id"); + int dataFieldId = fieldId("data"); + + StatisticsFile statisticsFile = + statisticsFile( + snapshot, + "/path/to/aggregated-statistics.puffin", + 200L, + blob(snapshot, "type-a", ImmutableList.of(idFieldId, dataFieldId)), + blob(snapshot, "type-a", ImmutableList.of(dataFieldId)), + blob(snapshot, "type-b", ImmutableList.of(idFieldId))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + PuffinRow row = onlyRow(metadataTable().newScan()); + assertThat(row.referencedBlobCount()).isEqualTo(3); + assertThat(row.referencedBlobTypes()).containsExactly("type-a", "type-b"); + assertThat(row.referencedFields()) + .containsExactly( + new ReferencedField(idFieldId, "id"), new ReferencedField(dataFieldId, "data")); + } + + @TestTemplate + public void testStatisticsReferenceSnapshotComesFromStatisticsFile() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot blobSnapshot = table.currentSnapshot(); + + table.newFastAppend().appendFile(FILE_B).commit(); + Snapshot referenceSnapshot = table.currentSnapshot(); + + StatisticsFile statisticsFile = + statisticsFile( + referenceSnapshot, + "/path/to/cross-snapshot-statistics.puffin", + 100L, + blob(blobSnapshot, "test-type", ImmutableList.of(fieldId("id")))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + PuffinRow row = onlyRow(metadataTable().newScan()); + assertThat(row.referenceSnapshotId()).isEqualTo(referenceSnapshot.snapshotId()); + } + + @TestTemplate + public void testStatisticsFileReplacementAndRemoval() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot snapshot = table.currentSnapshot(); + int idFieldId = fieldId("id"); + int dataFieldId = fieldId("data"); + + StatisticsFile oldStatistics = + statisticsFile( + snapshot, + "/path/to/old-statistics.puffin", + 100L, + blob(snapshot, "old-type", ImmutableList.of(idFieldId))); + + StatisticsFile newStatistics = + statisticsFile( + snapshot, + "/path/to/new-statistics.puffin", + 200L, + blob(snapshot, "new-type", ImmutableList.of(dataFieldId))); + + table.updateStatistics().setStatistics(oldStatistics).commit(); + table.updateStatistics().setStatistics(newStatistics).commit(); + + assertThat(rawTaskRows(metadataTable().newScan())) + .containsExactly( + new PuffinRow( + snapshot.snapshotId(), + newStatistics.path(), + SOURCE_STATISTICS, + newStatistics.fileSizeInBytes(), + 1, + ImmutableList.of("new-type"), + ImmutableList.of(new ReferencedField(dataFieldId, "data")))); + + table.updateStatistics().removeStatistics(snapshot.snapshotId()).commit(); + + assertThat(rawTaskRows(metadataTable().newScan())).isEmpty(); + } + + @TestTemplate + public void testCurrentFieldNameAfterRename() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot snapshot = table.currentSnapshot(); + int dataFieldId = fieldId("data"); + + table + .updateStatistics() + .setStatistics( + statisticsFile( + snapshot, + "/path/to/rename-statistics.puffin", + 100L, + blob(snapshot, "test-type", ImmutableList.of(dataFieldId)))) + .commit(); + + table.updateSchema().renameColumn("data", "renamed_data").commit(); + + assertThat(table.schema().findColumnName(dataFieldId)).isEqualTo("renamed_data"); + + PuffinRow row = onlyRow(metadataTable().newScan()); + assertThat(row.referencedFields()) + .containsExactly(new ReferencedField(dataFieldId, "renamed_data")); + } + + @TestTemplate + public void testCurrentFieldNameAfterDelete() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot snapshot = table.currentSnapshot(); + int idFieldId = fieldId("id"); + + table + .updateStatistics() + .setStatistics( + statisticsFile( + snapshot, + "/path/to/deleted-field-statistics.puffin", + 100L, + blob(snapshot, "test-type", ImmutableList.of(idFieldId)))) + .commit(); + + table.updateSchema().deleteColumn("id").commit(); + + assertThat(table.schema().findColumnName(idFieldId)).isNull(); + + PuffinRow row = onlyRow(metadataTable().newScan()); + assertThat(row.referencedFields()).containsExactly(new ReferencedField(idFieldId, null)); + } + + @TestTemplate + public void testDeletionVectorPuffinFile() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).commit(); + table.newRowDelta().addDeletes(FILE_A_DV).commit(); + Snapshot deletionVectorSnapshot = table.currentSnapshot(); + + assertThat(rawTaskRows(metadataTable().newScan())) + .containsExactly( + new PuffinRow( + deletionVectorSnapshot.snapshotId(), + FILE_A_DV.location(), + SOURCE_DELETION_VECTOR, + FILE_A_DV.fileSizeInBytes(), + 1, + ImmutableList.of(StandardBlobTypes.DV_V1), + ImmutableList.of( + new ReferencedField( + MetadataColumns.ROW_POSITION.fieldId(), + MetadataColumns.ROW_POSITION.name())))); + } + + @TestTemplate + public void testMultipleDeletionVectorsInSamePuffinFile() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + String sharedPuffinPath = "/path/to/shared-deletion-vectors.puffin"; + DeleteFile dvA = deletionVector(FILE_A, sharedPuffinPath, 100L, 4L, 10L); + DeleteFile dvB = deletionVector(FILE_B, sharedPuffinPath, 100L, 20L, 12L); + + table.newRowDelta().addDeletes(dvA).addDeletes(dvB).commit(); + Snapshot deletionVectorSnapshot = table.currentSnapshot(); + + assertThat(rawTaskRows(metadataTable().newScan())) + .containsExactly( + new PuffinRow( + deletionVectorSnapshot.snapshotId(), + sharedPuffinPath, + SOURCE_DELETION_VECTOR, + 100L, + 2, + ImmutableList.of(StandardBlobTypes.DV_V1), + ImmutableList.of( + new ReferencedField( + MetadataColumns.ROW_POSITION.fieldId(), + MetadataColumns.ROW_POSITION.name())))); + } + + @TestTemplate + public void testDuplicateDeletionVectorBlobIsCountedOnce() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + String sharedPuffinPath = "/path/to/duplicate-deletion-vector.puffin"; + DeleteFile dvA = deletionVector(FILE_A, sharedPuffinPath, 100L, 4L, 10L); + DeleteFile dvB = deletionVector(FILE_B, sharedPuffinPath, 100L, 4L, 10L); + + table.newRowDelta().addDeletes(dvA).addDeletes(dvB).commit(); + + PuffinRow row = onlyRow(metadataTable().newScan()); + assertThat(row.referencedBlobCount()).isEqualTo(1); + } + + @TestTemplate + public void testDeletionVectorsInSamePuffinFileMustHaveConsistentFileSize() { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + String sharedPuffinPath = "/path/to/inconsistent-deletion-vectors.puffin"; + DeleteFile dvA = deletionVector(FILE_A, sharedPuffinPath, 100L, 4L, 10L); + DeleteFile dvB = deletionVector(FILE_B, sharedPuffinPath, 200L, 20L, 12L); + + table.newRowDelta().addDeletes(dvA).addDeletes(dvB).commit(); + + assertThatThrownBy( + () -> + rawTaskRows( + metadataTable() + .newScan() + .filter(Expressions.equal("source", SOURCE_DELETION_VECTOR)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("different file sizes") + .hasMessageContaining(sharedPuffinPath); + } + + @TestTemplate + public void testEqualityDeletesAreNotPuffinFiles() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).commit(); + + DeleteFile equalityDelete = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes(fieldId("id")) + .withPath("/path/to/equality-deletes.parquet") + .withFileSizeInBytes(10L) + .withPartition(FILE_A.partition()) + .withRecordCount(1L) + .build(); + + table.newRowDelta().addDeletes(equalityDelete).commit(); + + assertThat( + rawTaskRows( + metadataTable() + .newScan() + .filter(Expressions.equal("source", SOURCE_DELETION_VECTOR)))) + .isEmpty(); + } + + @TestTemplate + public void testDeletionVectorRepeatedAcrossSnapshots() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newRowDelta().addDeletes(FILE_A_DV).commit(); + long deletionVectorSnapshotId = table.currentSnapshot().snapshotId(); + + table.newFastAppend().appendFile(FILE_B).commit(); + long laterSnapshotId = table.currentSnapshot().snapshotId(); + + List deletionVectorRows = deletionVectorRows(); + + assertThat(deletionVectorRows).hasSize(2); + assertThat(deletionVectorRows) + .extracting(PuffinRow::referenceSnapshotId) + .containsExactlyInAnyOrder(deletionVectorSnapshotId, laterSnapshotId); + assertThat(deletionVectorRows) + .extracting(PuffinRow::filePath) + .containsOnly(FILE_A_DV.location()); + } + + @TestTemplate + public void testDeletionVectorRemovedFromLaterSnapshot() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newRowDelta().addDeletes(FILE_A_DV).commit(); + long addedSnapshotId = table.currentSnapshot().snapshotId(); + + table.newFastAppend().appendFile(FILE_B).commit(); + long carriedSnapshotId = table.currentSnapshot().snapshotId(); + + table.newRowDelta().removeDeletes(FILE_A_DV).commit(); + long removedSnapshotId = table.currentSnapshot().snapshotId(); + + assertThat(deletionVectorRows()) + .extracting(PuffinRow::referenceSnapshotId) + .containsExactlyInAnyOrder(addedSnapshotId, carriedSnapshotId) + .doesNotContain(removedSnapshotId); + } + + @TestTemplate + public void testExpiredSnapshotIsNotReported() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newRowDelta().addDeletes(FILE_A_DV).commit(); + long expiredSnapshotId = table.currentSnapshot().snapshotId(); + + table.newFastAppend().appendFile(FILE_B).commit(); + long currentSnapshotId = table.currentSnapshot().snapshotId(); + + table + .expireSnapshots() + .expireSnapshotId(expiredSnapshotId) + .cleanupLevel(ExpireSnapshots.CleanupLevel.NONE) + .commit(); + + assertThat(deletionVectorRows()) + .extracting(PuffinRow::referenceSnapshotId) + .containsExactly(currentSnapshotId); + } + + @TestTemplate + public void testSourcePredicatePruning() throws IOException { + assumeDeletionVectorsSupported(); + PreparedTable prepared = prepareStatisticsAndDeletionVector(); + + assertPlannedTaskFormats(Expressions.equal("source", SOURCE_STATISTICS), FileFormat.METADATA); + assertPlannedTaskFormats(Expressions.notEqual("source", SOURCE_STATISTICS), FileFormat.AVRO); + assertPlannedTaskFormats(Expressions.in("source", SOURCE_STATISTICS), FileFormat.METADATA); + assertPlannedTaskFormats(Expressions.notIn("source", SOURCE_STATISTICS), FileFormat.AVRO); + assertPlannedTaskFormats(Expressions.startsWith("source", "stat"), FileFormat.METADATA); + assertPlannedTaskFormats(Expressions.notStartsWith("source", "stat"), FileFormat.AVRO); + assertPlannedTaskFormats(Expressions.isNull("source")); + assertPlannedTaskFormats(Expressions.notNull("source"), FileFormat.METADATA, FileFormat.AVRO); + + FileScanTask deletionVectorTask = + onlyTaskWithFormat( + plannedTasks( + metadataTable() + .newScan() + .filter(Expressions.equal("source", SOURCE_DELETION_VECTOR))), + FileFormat.AVRO); + assertThat(deletionVectorTask.file().location()) + .isEqualTo(prepared.deletionVectorManifestList()); + } + + @TestTemplate + public void testSourcePruningUsesCaseInsensitiveBinding() throws IOException { + assumeDeletionVectorsSupported(); + prepareStatisticsAndDeletionVector(); + + List tasks = + plannedTasks( + metadataTable() + .newScan() + .caseSensitive(false) + .filter(Expressions.equal("SOURCE", SOURCE_STATISTICS))); + + assertThat(tasks).extracting(task -> task.file().format()).containsExactly(FileFormat.METADATA); + } + + @TestTemplate + public void testReferenceSnapshotIdPredicatePruning() throws IOException { + assumeDeletionVectorsSupported(); + PreparedTable prepared = prepareStatisticsAndDeletionVector(); + + assertPlannedTaskFormats( + Expressions.equal("reference_snapshot_id", prepared.statisticsSnapshotId()), + FileFormat.METADATA); + assertPlannedTaskFormats( + Expressions.notEqual("reference_snapshot_id", prepared.statisticsSnapshotId()), + FileFormat.AVRO); + assertPlannedTaskFormats( + Expressions.in("reference_snapshot_id", prepared.statisticsSnapshotId()), + FileFormat.METADATA); + assertPlannedTaskFormats( + Expressions.notIn("reference_snapshot_id", prepared.statisticsSnapshotId()), + FileFormat.AVRO); + assertPlannedTaskFormats(Expressions.isNull("reference_snapshot_id")); + assertPlannedTaskFormats( + Expressions.notNull("reference_snapshot_id"), FileFormat.METADATA, FileFormat.AVRO); + } + + @TestTemplate + public void testReferenceSnapshotIdComparisonPruning() throws IOException { + assumeDeletionVectorsSupported(); + PreparedTable prepared = prepareStatisticsAndDeletionVector(); + + long lowerSnapshotId = + Math.min(prepared.statisticsSnapshotId(), prepared.deletionVectorSnapshotId()); + long higherSnapshotId = + Math.max(prepared.statisticsSnapshotId(), prepared.deletionVectorSnapshotId()); + + assertPlannedTaskFormats( + Expressions.lessThan("reference_snapshot_id", higherSnapshotId), + formatForSnapshot(prepared, lowerSnapshotId)); + assertPlannedTaskFormats( + Expressions.greaterThanOrEqual("reference_snapshot_id", higherSnapshotId), + formatForSnapshot(prepared, higherSnapshotId)); + assertPlannedTaskFormats( + Expressions.lessThanOrEqual("reference_snapshot_id", lowerSnapshotId), + formatForSnapshot(prepared, lowerSnapshotId)); + assertPlannedTaskFormats( + Expressions.greaterThan("reference_snapshot_id", lowerSnapshotId), + formatForSnapshot(prepared, higherSnapshotId)); + } + + @TestTemplate + public void testBooleanPredicatePruning() throws IOException { + assumeDeletionVectorsSupported(); + PreparedTable prepared = prepareStatisticsAndDeletionVector(); + + Expression unknown = Expressions.equal("file_path", "/unknown/path.puffin"); + + assertPlannedTaskFormats( + Expressions.and(unknown, Expressions.equal("source", SOURCE_STATISTICS)), + FileFormat.METADATA); + assertPlannedTaskFormats(Expressions.and(unknown, Expressions.equal("source", "unknown"))); + assertPlannedTaskFormats( + Expressions.or(unknown, Expressions.equal("source", SOURCE_STATISTICS)), + FileFormat.METADATA, + FileFormat.AVRO); + assertPlannedTaskFormats(Expressions.not(unknown), FileFormat.METADATA, FileFormat.AVRO); + assertPlannedTaskFormats( + Expressions.not(Expressions.equal("source", SOURCE_STATISTICS)), FileFormat.AVRO); + assertPlannedTaskFormats( + Expressions.and( + Expressions.equal("source", SOURCE_STATISTICS), + Expressions.equal("reference_snapshot_id", prepared.statisticsSnapshotId())), + FileFormat.METADATA); + assertPlannedTaskFormats( + Expressions.or( + Expressions.equal("source", SOURCE_STATISTICS), + Expressions.equal("reference_snapshot_id", prepared.deletionVectorSnapshotId())), + FileFormat.METADATA, + FileFormat.AVRO); + } + + @TestTemplate + public void testDeletionVectorTaskPreservesUnknownPredicateAsResidual() throws IOException { + assumeDeletionVectorsSupported(); + prepareStatisticsAndDeletionVector(); + + Expression filter = Expressions.greaterThan("referenced_blob_count", 100); + List tasks = plannedTasks(metadataTable().newScan().filter(filter)); + FileScanTask deletionVectorTask = onlyTaskWithFormat(tasks, FileFormat.AVRO); + + assertThat( + ExpressionUtil.equivalent( + filter, deletionVectorTask.residual(), metadataTable().schema().asStruct(), true)) + .isTrue(); + } + + @TestTemplate + public void testProjection() throws IOException { + assumeDeletionVectorsSupported(); + PreparedTable prepared = prepareStatisticsAndDeletionVector(); + + Table metadataTable = metadataTable(); + TableScan scan = metadataTable.newScan().select("reference_snapshot_id", "source"); + Schema expectedSchema = metadataTable.schema().select("reference_snapshot_id", "source"); + + assertThat(scan.schema().sameSchema(expectedSchema)) + .as("Projected scan schema should match the selected metadata-table schema") + .isTrue(); + + List projectedRows = Lists.newArrayList(); + try (CloseableIterable tasks = scan.planFiles()) { + for (FileScanTask task : tasks) { + try (CloseableIterable taskRows = task.asDataTask().rows()) { + for (StructLike row : taskRows) { + assertThat(row.size()).isEqualTo(2); + projectedRows.add( + new SnapshotSourceRow( + row.get(0, Long.class), row.get(1, CharSequence.class).toString())); + } + } + } + } + + assertThat(projectedRows) + .containsExactlyInAnyOrder( + new SnapshotSourceRow(prepared.statisticsSnapshotId(), SOURCE_STATISTICS), + new SnapshotSourceRow(prepared.deletionVectorSnapshotId(), SOURCE_DELETION_VECTOR)); + } + + @TestTemplate + public void testReferencedFieldsProjection() throws IOException { + assumeDeletionVectorsSupported(); + prepareStatisticsAndDeletionVector(); + + Table metadataTable = metadataTable(); + TableScan scan = metadataTable.newScan().select("referenced_fields"); + Schema expectedSchema = metadataTable.schema().select("referenced_fields"); + + assertThat(scan.schema().sameSchema(expectedSchema)).isTrue(); + + Set referencedFields = Sets.newHashSet(); + try (CloseableIterable tasks = scan.planFiles()) { + for (FileScanTask task : tasks) { + try (CloseableIterable taskRows = task.asDataTask().rows()) { + for (StructLike row : taskRows) { + assertThat(row.size()).isEqualTo(1); + + List fields = row.get(0, List.class); + for (Object fieldObject : fields) { + StructLike field = (StructLike) fieldObject; + assertThat(field.size()).isEqualTo(2); + + CharSequence currentFieldName = field.get(1, CharSequence.class); + referencedFields.add( + new ReferencedField( + field.get(0, Integer.class), + currentFieldName != null ? currentFieldName.toString() : null)); + } + } + } + } + } + + assertThat(referencedFields) + .containsExactlyInAnyOrder( + new ReferencedField(fieldId("id"), "id"), + new ReferencedField( + MetadataColumns.ROW_POSITION.fieldId(), MetadataColumns.ROW_POSITION.name())); + } + + @TestTemplate + public void testDeletionVectorTaskHonorsIgnoreResiduals() throws IOException { + assumeDeletionVectorsSupported(); + + table.newFastAppend().appendFile(FILE_A).commit(); + table.newRowDelta().addDeletes(FILE_A_DV).commit(); + + Expression filter = Expressions.greaterThan("referenced_blob_count", 0); + + FileScanTask task = onlyTask(plannedTasks(metadataTable().newScan().filter(filter))); + assertThat( + ExpressionUtil.equivalent( + filter, task.residual(), metadataTable().schema().asStruct(), true)) + .isTrue(); + + FileScanTask taskWithoutResidual = + onlyTask(plannedTasks(metadataTable().newScan().filter(filter).ignoreResiduals())); + + assertThat(taskWithoutResidual.residual()).isEqualTo(Expressions.alwaysTrue()); + } + + private FileScanTask onlyTask(List tasks) { + assertThat(tasks).hasSize(1); + return tasks.get(0); + } + + private Table metadataTable() { + return new AllPuffinFilesTable(table); + } + + private int fieldId(String name) { + Types.NestedField field = table.schema().findField(name); + assertThat(field).as("Field %s should exist in the current table schema", name).isNotNull(); + return field.fieldId(); + } + + private void assumeDeletionVectorsSupported() { + assumeThat(formatVersion) + .as("Deletion vectors require format version 3 or later") + .isGreaterThanOrEqualTo(3); + } + + private PreparedTable prepareStatisticsAndDeletionVector() { + table.newFastAppend().appendFile(FILE_A).commit(); + Snapshot statisticsSnapshot = table.currentSnapshot(); + + table + .updateStatistics() + .setStatistics( + statisticsFile( + statisticsSnapshot, + "/path/to/source-pruning-statistics.puffin", + 100L, + blob(statisticsSnapshot, "test-type", ImmutableList.of(fieldId("id"))))) + .commit(); + + table.newRowDelta().addDeletes(FILE_A_DV).commit(); + Snapshot deletionVectorSnapshot = table.currentSnapshot(); + + return new PreparedTable( + statisticsSnapshot.snapshotId(), + deletionVectorSnapshot.snapshotId(), + deletionVectorSnapshot.manifestListLocation()); + } + + private List deletionVectorRows() throws IOException { + return rawTaskRows( + metadataTable().newScan().filter(Expressions.equal("source", SOURCE_DELETION_VECTOR))); + } + + private void assertPlannedTaskFormats(Expression filter, FileFormat... expectedFormats) + throws IOException { + assertThat(plannedTasks(metadataTable().newScan().filter(filter))) + .extracting(task -> task.file().format()) + .containsExactlyInAnyOrder(expectedFormats); + } + + private FileFormat formatForSnapshot(PreparedTable prepared, long snapshotId) { + if (snapshotId == prepared.statisticsSnapshotId()) { + return FileFormat.METADATA; + } + + assertThat(snapshotId).isEqualTo(prepared.deletionVectorSnapshotId()); + return FileFormat.AVRO; + } + + private FileScanTask onlyTaskWithFormat(List tasks, FileFormat format) { + List matchingTasks = + tasks.stream() + .filter(task -> task.file().format() == format) + .collect(ImmutableList.toImmutableList()); + + assertThat(matchingTasks).hasSize(1); + return matchingTasks.get(0); + } + + private static StatisticsFile statisticsFile( + Snapshot snapshot, String path, long fileSizeInBytes, BlobMetadata... blobs) { + return new GenericStatisticsFile( + snapshot.snapshotId(), path, fileSizeInBytes, 10L, ImmutableList.copyOf(blobs)); + } + + private static BlobMetadata blob(Snapshot snapshot, String type, List fields) { + return new GenericBlobMetadata( + type, snapshot.snapshotId(), snapshot.sequenceNumber(), fields, ImmutableMap.of()); + } + + private static DeleteFile deletionVector( + DataFile referencedDataFile, + String puffinPath, + long fileSizeInBytes, + long contentOffset, + long contentSizeInBytes) { + return FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath(puffinPath) + .withFileSizeInBytes(fileSizeInBytes) + .withPartition(referencedDataFile.partition()) + .withRecordCount(1L) + .withReferencedDataFile(referencedDataFile.location()) + .withContentOffset(contentOffset) + .withContentSizeInBytes(contentSizeInBytes) + .build(); + } + + private PuffinRow onlyRow(TableScan scan) throws IOException { + List rows = rawTaskRows(scan); + assertThat(rows).hasSize(1); + return rows.get(0); + } + + private List plannedTasks(TableScan scan) throws IOException { + try (CloseableIterable tasks = scan.planFiles()) { + return Lists.newArrayList(tasks); + } + } + + /** Returns raw data-task rows without evaluating task residuals. */ + private List rawTaskRows(TableScan scan) throws IOException { + List results = Lists.newArrayList(); + + try (CloseableIterable tasks = scan.planFiles()) { + for (FileScanTask task : tasks) { + try (CloseableIterable taskRows = task.asDataTask().rows()) { + for (StructLike row : taskRows) { + results.add(copyRow(row)); + } + } + } + } + + return results; + } + + private static PuffinRow copyRow(StructLike row) { + CharSequence filePath = row.get(1, CharSequence.class); + CharSequence source = row.get(2, CharSequence.class); + + List rawBlobTypes = row.get(5, List.class); + List blobTypes = + rawBlobTypes.stream().map(Object::toString).collect(ImmutableList.toImmutableList()); + + List rawReferencedFields = row.get(6, List.class); + List referencedFields = + rawReferencedFields.stream() + .map(StructLike.class::cast) + .map( + field -> { + CharSequence currentName = field.get(1, CharSequence.class); + return new ReferencedField( + field.get(0, Integer.class), + currentName != null ? currentName.toString() : null); + }) + .collect(ImmutableList.toImmutableList()); + + return new PuffinRow( + row.get(0, Long.class), + filePath.toString(), + source.toString(), + row.get(3, Long.class), + row.get(4, Integer.class), + blobTypes, + referencedFields); + } + + private record PreparedTable( + long statisticsSnapshotId, + long deletionVectorSnapshotId, + String deletionVectorManifestList) {} + + private record SnapshotSourceRow(long referenceSnapshotId, String source) {} + + private record PuffinRow( + long referenceSnapshotId, + String filePath, + String source, + long fileSizeInBytes, + int referencedBlobCount, + List referencedBlobTypes, + List referencedFields) {} + + private record ReferencedField(int fieldId, String currentFieldName) {} +} diff --git a/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java b/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java index ece9b24af3d1..aff5e353edc4 100644 --- a/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java +++ b/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java @@ -35,6 +35,7 @@ import java.util.Set; import org.apache.iceberg.BaseTable; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.GenericStatisticsFile; import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.MetadataTableType; import org.apache.iceberg.MetadataTableUtils; @@ -42,6 +43,7 @@ import org.apache.iceberg.PositionDeletesTable; import org.apache.iceberg.ScanTask; import org.apache.iceberg.SerializableTable; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.StaticTableOperations; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -49,6 +51,7 @@ import org.apache.iceberg.TestHelpers; import org.apache.iceberg.Transaction; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Types; @@ -183,6 +186,18 @@ public void testSerializableMetadataTablesPlanning(boolean fromSerialized) throw table.newAppend().appendFile(FILE_B).commit(); table.newRowDelta().addDeletes(FILE_B_DELETES).commit(); + Snapshot currentSnapshot = table.currentSnapshot(); + table + .updateStatistics() + .setStatistics( + new GenericStatisticsFile( + currentSnapshot.snapshotId(), + "/path/to/new-statistics.puffin", + 100L, + 10L, + ImmutableList.of())) + .commit(); + for (MetadataTableType type : MetadataTableType.values()) { // Collect the deserialized data Set deserializedFiles = getFiles(deserializeFromBytes(serialized.get(type))); diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java index 117bf7fac24b..74488e3e4d2d 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java @@ -382,6 +382,8 @@ public void metadataTablesWithRemotePlanning(MetadataTableType type) { if (type.equals(MetadataTableType.POSITION_DELETES)) { // Position deletes table only uses batch scan assertThat(metadataTableInstance.newBatchScan().planFiles()).isNotEmpty(); + } else if (type.equals(MetadataTableType.ALL_PUFFIN_FILES)) { + assertThat(metadataTableInstance.newScan().planFiles()).isEmpty(); } else { assertThat(metadataTableInstance.newScan().planFiles()).isNotEmpty(); }