diff --git a/core/src/main/java/org/apache/iceberg/actions/BaseRepairTable.java b/core/src/main/java/org/apache/iceberg/actions/BaseRepairTable.java
new file mode 100644
index 000000000000..929f3bd3194b
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/actions/BaseRepairTable.java
@@ -0,0 +1,33 @@
+/*
+ * 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.actions;
+
+import org.immutables.value.Value;
+
+@Value.Enclosing
+@SuppressWarnings("ImmutablesStyle")
+@Value.Style(
+ typeImmutableEnclosing = "ImmutableRepairTable",
+ visibilityString = "PUBLIC",
+ builderVisibilityString = "PUBLIC")
+interface BaseRepairTable extends RepairTable {
+
+ @Value.Immutable
+ interface Result extends RepairTable.Result {}
+}
diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RepairMetrics.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RepairMetrics.java
new file mode 100644
index 000000000000..1d5f1c88bcc8
--- /dev/null
+++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RepairMetrics.java
@@ -0,0 +1,195 @@
+/*
+ * 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.spark.actions;
+
+import static org.apache.iceberg.TableProperties.DEFAULT_NAME_MAPPING;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.iceberg.ContentFile;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileContent;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.Metrics;
+import org.apache.iceberg.MetricsConfig;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.avro.Avro;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.mapping.NameMapping;
+import org.apache.iceberg.mapping.NameMappingParser;
+import org.apache.iceberg.orc.OrcMetrics;
+import org.apache.iceberg.parquet.ParquetUtil;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+
+/**
+ * Reads the statistics of data and delete files and compares them against the statistics recorded
+ * in manifest entries.
+ *
+ *
Recomputed statistics always respect the metrics config of the table so that they are
+ * comparable with the stored statistics.
+ */
+class RepairMetrics {
+
+ private RepairMetrics() {}
+
+ /** Returns the name mapping of the table, or null if the table does not define one. */
+ static NameMapping nameMapping(Table table) {
+ String mapping = table.properties().get(DEFAULT_NAME_MAPPING);
+ return mapping != null ? NameMappingParser.fromJson(mapping) : null;
+ }
+
+ /**
+ * Returns the metrics config to use when recomputing the statistics of the given file.
+ *
+ *
Position delete files record statistics for the path and position columns only, which is a
+ * fixed config rather than the config of the table.
+ */
+ static MetricsConfig metricsConfig(Table table, FileContent content) {
+ return content == FileContent.POSITION_DELETES
+ ? MetricsConfig.forPositionDelete()
+ : MetricsConfig.forTable(table);
+ }
+
+ /**
+ * Returns true if the statistics of the file can be recomputed by reading it.
+ *
+ *
Deletion vectors are stored as blobs inside a Puffin file, so their statistics cannot be
+ * derived by reading the file they are stored in.
+ */
+ static boolean supportsMetrics(ContentFile> file) {
+ FileFormat format = file.format();
+ return format == FileFormat.PARQUET || format == FileFormat.ORC || format == FileFormat.AVRO;
+ }
+
+ /** Recomputes the statistics of a file by reading it. */
+ static Metrics readMetrics(
+ InputFile input, ContentFile> file, MetricsConfig config, NameMapping mapping) {
+ switch (file.format()) {
+ case PARQUET:
+ return ParquetUtil.fileMetrics(input, config, mapping);
+ case ORC:
+ return OrcMetrics.fromInputFile(input, config, mapping);
+ case AVRO:
+ // Avro does not record column statistics, only the number of records is recoverable
+ return new Metrics(Avro.rowCount(input), null, null, null, null);
+ default:
+ throw new UnsupportedOperationException("Cannot read metrics of format: " + file.format());
+ }
+ }
+
+ /**
+ * Returns true if the statistics recorded for the file differ from the statistics of the file
+ * itself.
+ *
+ *
The record count and the file size are always compared. Column level statistics are only
+ * compared when requested, because a table whose metrics config changed after a file was written
+ * reports statistics that legitimately differ from the recomputed ones.
+ */
+ static boolean statsAreIncorrect(
+ ContentFile> file, Metrics metrics, long fileSizeInBytes, boolean compareColumnMetrics) {
+ if (file.fileSizeInBytes() != fileSizeInBytes) {
+ return true;
+ }
+
+ if (metrics.recordCount() != null && file.recordCount() != metrics.recordCount()) {
+ return true;
+ }
+
+ if (!compareColumnMetrics) {
+ return false;
+ }
+
+ return !countsMatch(file.columnSizes(), metrics.columnSizes())
+ || !countsMatch(file.valueCounts(), metrics.valueCounts())
+ || !countsMatch(file.nullValueCounts(), metrics.nullValueCounts())
+ || !countsMatch(file.nanValueCounts(), metrics.nanValueCounts())
+ || !boundsMatch(file.lowerBounds(), metrics.lowerBounds())
+ || !boundsMatch(file.upperBounds(), metrics.upperBounds());
+ }
+
+ /**
+ * Rebuilds the file with the given statistics, preserving every other field.
+ *
+ *
The file size is passed separately as it is not part of the metrics of a file.
+ */
+ static ContentFile> withStats(
+ ContentFile> file, PartitionSpec spec, Metrics metrics, long fileSizeInBytes) {
+ if (file.content() == FileContent.DATA) {
+ return DataFiles.builder(spec)
+ .copy((DataFile) file)
+ .withMetrics(metrics)
+ .withFileSizeInBytes(fileSizeInBytes)
+ .build();
+ }
+
+ DeleteFile delete = (DeleteFile) file;
+ FileMetadata.Builder builder =
+ FileMetadata.deleteFileBuilder(spec)
+ .copy(delete)
+ .withMetrics(metrics)
+ .withFileSizeInBytes(fileSizeInBytes);
+ List equalityFieldIds = delete.equalityFieldIds();
+ if (delete.content() == FileContent.EQUALITY_DELETES
+ && equalityFieldIds != null
+ && !equalityFieldIds.isEmpty()) {
+ // copy(DeleteFile) drops the equality field ids, so they must be set again. Otherwise the
+ // rewritten entry keeps content EQUALITY_DELETES with null equality ids, which makes reads
+ // fail once the delete is applied. An entry that already lacks them is carried through as is.
+ builder.ofEqualityDeletes(equalityFieldIds.stream().mapToInt(Integer::intValue).toArray());
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Returns metrics carrying the recomputed record count but the column-level statistics stored for
+ * the file, used when column metrics are not being repaired so that a flagged entry has only its
+ * record count and file size corrected.
+ */
+ static Metrics recordCountOnly(ContentFile> file, Metrics recomputed) {
+ return new Metrics(
+ recomputed.recordCount(),
+ file.columnSizes(),
+ file.valueCounts(),
+ file.nullValueCounts(),
+ file.nanValueCounts(),
+ file.lowerBounds(),
+ file.upperBounds());
+ }
+
+ private static boolean countsMatch(Map stored, Map actual) {
+ return Objects.equals(normalize(stored), normalize(actual));
+ }
+
+ private static boolean boundsMatch(
+ Map stored, Map actual) {
+ return Objects.equals(normalize(stored), normalize(actual));
+ }
+
+ /** Treats a missing map and an empty map as equivalent, as writers use both. */
+ private static Map normalize(Map map) {
+ return map == null ? ImmutableMap.of() : map;
+ }
+}
diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RepairTableSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RepairTableSparkAction.java
new file mode 100644
index 000000000000..43c676b042fb
--- /dev/null
+++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RepairTableSparkAction.java
@@ -0,0 +1,820 @@
+/*
+ * 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.spark.actions;
+
+import static org.apache.iceberg.MetadataTableType.ENTRIES;
+
+import java.io.Serializable;
+import java.util.EnumMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.apache.hadoop.fs.Path;
+import org.apache.iceberg.ContentFile;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileContent;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.ManifestContent;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestWriter;
+import org.apache.iceberg.Metrics;
+import org.apache.iceberg.MetricsConfig;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Partitioning;
+import org.apache.iceberg.RollingManifestWriter;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableOperations;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.actions.ImmutableRepairTable;
+import org.apache.iceberg.actions.RepairTable;
+import org.apache.iceberg.exceptions.CleanableFailure;
+import org.apache.iceberg.exceptions.CommitStateUnknownException;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.io.SupportsBulkOperations;
+import org.apache.iceberg.mapping.NameMapping;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.spark.JobGroupInfo;
+import org.apache.iceberg.spark.SparkContentFile;
+import org.apache.iceberg.spark.SparkDataFile;
+import org.apache.iceberg.spark.SparkDeleteFile;
+import org.apache.iceberg.spark.source.SerializableTableWithSize;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.PropertyUtil;
+import org.apache.iceberg.util.ThreadPools;
+import org.apache.spark.api.java.function.MapPartitionsFunction;
+import org.apache.spark.broadcast.Broadcast;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Encoder;
+import org.apache.spark.sql.Encoders;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.functions;
+import org.apache.spark.sql.types.StructType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import scala.Tuple2;
+
+/**
+ * An action that repairs incorrect statistics in the manifests of a table.
+ *
+ *
The statistics of every live manifest entry are compared against the file the entry refers to.
+ * Only manifests that contain at least one incorrect entry are rewritten, so the cost of the commit
+ * is proportional to the number of incorrect entries rather than to the size of the table.
+ */
+public class RepairTableSparkAction extends BaseSnapshotUpdateSparkAction
+ implements RepairTable {
+
+ public static final String USE_CACHING = "use-caching";
+ public static final boolean USE_CACHING_DEFAULT = false;
+
+ /**
+ * Whether to compare and repair column level statistics. When disabled, only record counts and
+ * file sizes are compared and repaired.
+ *
+ *
This is disabled by default. Recomputed column statistics reflect the current metrics config
+ * of the table, but the config a file was written under is not recorded, so a table whose config
+ * changed reports column statistics that legitimately differ from the recomputed ones. Repairing
+ * them in that case would overwrite correct statistics. Reading the footer of every candidate
+ * file happens regardless of this option; it only controls whether column statistics are
+ * compared.
+ */
+ public static final String REPAIR_COLUMN_METRICS = "repair-column-metrics";
+
+ public static final boolean REPAIR_COLUMN_METRICS_DEFAULT = false;
+
+ private static final Logger LOG = LoggerFactory.getLogger(RepairTableSparkAction.class);
+
+ private static final RepairTable.Result EMPTY_RESULT =
+ ImmutableRepairTable.Result.builder()
+ .repairedManifests(ImmutableList.of())
+ .repairedEntryCount(0L)
+ .build();
+
+ private static final String NEW_MANIFEST_PREFIX = "repaired-m-";
+
+ private final Table table;
+ private final int formatVersion;
+ private final long targetManifestSizeBytes;
+ private final boolean shouldStageManifests;
+ private final String outputLocation;
+
+ private boolean repairFileMetrics = false;
+ private boolean dryRun = false;
+
+ RepairTableSparkAction(SparkSession spark, Table table) {
+ super(spark);
+ this.table = table;
+ this.targetManifestSizeBytes =
+ PropertyUtil.propertyAsLong(
+ table.properties(),
+ TableProperties.MANIFEST_TARGET_SIZE_BYTES,
+ TableProperties.MANIFEST_TARGET_SIZE_BYTES_DEFAULT);
+
+ TableOperations ops = ((HasTableOperations) table).operations();
+ Path metadataFilePath = new Path(ops.metadataFileLocation("file"));
+ this.outputLocation = metadataFilePath.getParent().toString();
+ this.formatVersion = ops.current().formatVersion();
+
+ boolean snapshotIdInheritanceEnabled =
+ PropertyUtil.propertyAsBoolean(
+ table.properties(),
+ TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED,
+ TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED_DEFAULT);
+ this.shouldStageManifests = formatVersion == 1 && !snapshotIdInheritanceEnabled;
+ }
+
+ @Override
+ protected RepairTableSparkAction self() {
+ return this;
+ }
+
+ @Override
+ public RepairTableSparkAction repairFileMetrics() {
+ this.repairFileMetrics = true;
+ return this;
+ }
+
+ @Override
+ public RepairTableSparkAction dryRun() {
+ this.dryRun = true;
+ return this;
+ }
+
+ @Override
+ public RepairTable.Result execute() {
+ String desc = String.format("Repairing manifests in %s (dryRun=%s)", table.name(), dryRun);
+ JobGroupInfo info = newJobGroupInfo("REPAIR-TABLE", desc);
+ return withJobGroupInfo(info, this::doExecute);
+ }
+
+ private RepairTable.Result doExecute() {
+ if (!repairFileMetrics) {
+ // no repair was selected through the configuration methods, so there is nothing to do
+ return EMPTY_RESULT;
+ }
+
+ Snapshot currentSnapshot = table.currentSnapshot();
+ if (currentSnapshot == null) {
+ return EMPTY_RESULT;
+ }
+
+ List repairedManifests = Lists.newArrayList();
+ List newManifests = Lists.newArrayList();
+ long repairedCount = 0L;
+
+ for (ManifestContent content : ManifestContent.values()) {
+ RepairedManifests repaired = repairTable(content, currentSnapshot);
+ repairedManifests.addAll(repaired.repairedManifests());
+ newManifests.addAll(repaired.newManifests());
+ repairedCount += repaired.repairedCount();
+ }
+
+ if (repairedManifests.isEmpty()) {
+ return EMPTY_RESULT;
+ }
+
+ // a dry run writes no manifests, so there is nothing to commit or clean up
+ if (!dryRun) {
+ replaceManifests(repairedManifests, newManifests);
+ }
+
+ LOG.info(
+ "Repaired the stats of {} manifest entries, rewriting {} manifests as {} (dryRun={})",
+ repairedCount,
+ repairedManifests.size(),
+ newManifests.size(),
+ dryRun);
+
+ return ImmutableRepairTable.Result.builder()
+ .repairedManifests(repairedManifests)
+ .repairedEntryCount(repairedCount)
+ .build();
+ }
+
+ private RepairedManifests repairTable(ManifestContent content, Snapshot snapshot) {
+ List manifests = loadManifests(content, snapshot);
+ if (manifests.isEmpty()) {
+ return RepairedManifests.empty();
+ }
+
+ // A manifest is rewritten with the spec it was written under, so manifests are grouped by
+ // spec and each group is repaired separately. Rewriting a manifest of an older spec with the
+ // current spec of the table would change the partition data of its entries.
+ Map> manifestsBySpecId =
+ manifests.stream().collect(Collectors.groupingBy(ManifestFile::partitionSpecId));
+
+ List repairedManifests = Lists.newArrayList();
+ List newManifests = Lists.newArrayList();
+ long repairedCount = 0L;
+
+ for (Map.Entry> group : manifestsBySpecId.entrySet()) {
+ RepairedManifests repaired = repairManifests(content, group.getKey(), group.getValue());
+ repairedManifests.addAll(repaired.repairedManifests());
+ newManifests.addAll(repaired.newManifests());
+ repairedCount += repaired.repairedCount();
+ }
+
+ return RepairedManifests.of(repairedManifests, newManifests, repairedCount);
+ }
+
+ private RepairedManifests repairManifests(
+ ManifestContent content, int specId, List manifests) {
+ Dataset entryDF = buildManifestEntryDF(manifests);
+
+ return withReusableDS(
+ entryDF,
+ df -> {
+ // the entries whose stats disagree with the files they refer to, as (manifest, path).
+ // cached because it is small, one row per incorrect entry, and is read by several actions
+ // below, whereas recomputing it would re-read every file.
+ Dataset verdicts =
+ df.mapPartitions(
+ newCheckStatsFunc(content, specId),
+ Encoders.tuple(Encoders.STRING(), Encoders.STRING()))
+ .toDF("manifest", "path")
+ .cache();
+
+ try {
+ List manifestsToRewrite =
+ verdicts.select("manifest").distinct().as(Encoders.STRING()).collectAsList();
+
+ if (manifestsToRewrite.isEmpty()) {
+ return RepairedManifests.empty();
+ }
+
+ long repairedCount = verdicts.count();
+ List rewritten =
+ manifests.stream()
+ .filter(manifest -> manifestsToRewrite.contains(manifest.path()))
+ .collect(Collectors.toList());
+
+ // a dry run reports what would be repaired without writing any manifests
+ if (dryRun) {
+ return RepairedManifests.of(rewritten, ImmutableList.of(), repairedCount);
+ }
+
+ // mark every entry of the affected manifests with whether its stats need repair by
+ // joining on the file path, so the unbounded per file set stays distributed rather than
+ // being collected to the driver and broadcast back out
+ Dataset entriesToRewrite =
+ df.filter(df.col("manifest").isin(manifestsToRewrite.toArray()));
+ Dataset markedEntries = markEntriesToRepair(entriesToRewrite, verdicts);
+ List written =
+ writeManifests(content, specId, markedEntries, rewritten.size());
+
+ return RepairedManifests.of(rewritten, written, repairedCount);
+ } finally {
+ verdicts.unpersist(false);
+ }
+ });
+ }
+
+ /**
+ * Marks every entry with a boolean {@code repair} column that is true when the entry's file has
+ * incorrect statistics, by left joining the entries against the verdicts on the file path.
+ */
+ private Dataset markEntriesToRepair(Dataset entries, Dataset verdicts) {
+ Dataset repairedPaths =
+ verdicts.select(verdicts.col("path").as("repaired_path")).distinct();
+ return entries
+ .join(
+ repairedPaths,
+ entries.col("data_file.file_path").equalTo(repairedPaths.col("repaired_path")),
+ "left")
+ .withColumn("repair", functions.col("repaired_path").isNotNull())
+ .drop("repaired_path")
+ .select(
+ "manifest",
+ "snapshot_id",
+ "sequence_number",
+ "file_sequence_number",
+ "data_file",
+ "repair");
+ }
+
+ /**
+ * Loads the live entries of the given manifests, keeping the manifest each entry was read from so
+ * that only the manifests containing an incorrect entry are rewritten.
+ */
+ private Dataset buildManifestEntryDF(List manifests) {
+ Dataset manifestDF =
+ spark()
+ .createDataset(Lists.transform(manifests, ManifestFile::path), Encoders.STRING())
+ .toDF("manifest");
+
+ Dataset entryDF =
+ loadMetadataTable(table, ENTRIES)
+ .filter("status < 2") // select only live entries
+ .selectExpr(
+ "input_file_name() as manifest",
+ "snapshot_id",
+ "sequence_number",
+ "file_sequence_number",
+ "data_file");
+
+ return entryDF.join(
+ manifestDF, manifestDF.col("manifest").equalTo(entryDF.col("manifest")), "left_semi");
+ }
+
+ private List writeManifests(
+ ManifestContent content, int specId, Dataset entryDF, int numManifests) {
+ StructType sparkType = (StructType) entryDF.schema().apply("data_file").dataType();
+ Types.StructType combinedFileType = DataFile.getType(Partitioning.partitionType(table));
+ Types.StructType fileType = DataFile.getType(table.specs().get(specId).partitionType());
+ ManifestWriterFactory writers = manifestWriters(specId);
+ RepairContext context = newRepairContext(content, specId);
+
+ WriteManifests> writeFunc =
+ content == ManifestContent.DATA
+ ? new WriteDataManifests(writers, combinedFileType, fileType, sparkType, context)
+ : new WriteDeleteManifests(writers, combinedFileType, fileType, sparkType, context);
+
+ // repartition by manifest so the entries of each manifest are written together and the layout
+ // of the table is preserved, rather than scattered round robin as a plain repartition(n) would.
+ // this produces about as many manifests as are being replaced.
+ return writeFunc
+ .apply(entryDF.repartition(numManifests, entryDF.col("manifest")))
+ .collectAsList();
+ }
+
+ private CheckStats newCheckStatsFunc(ManifestContent content, int specId) {
+ return new CheckStats(newRepairContext(content, specId));
+ }
+
+ private RepairContext newRepairContext(ManifestContent content, int specId) {
+ boolean repairColumnMetrics =
+ PropertyUtil.propertyAsBoolean(
+ options(), REPAIR_COLUMN_METRICS, REPAIR_COLUMN_METRICS_DEFAULT);
+ return new RepairContext(
+ sparkContext().broadcast(SerializableTableWithSize.copyOf(table)),
+ content,
+ specId,
+ repairColumnMetrics);
+ }
+
+ private List loadManifests(ManifestContent content, Snapshot snapshot) {
+ switch (content) {
+ case DATA:
+ return snapshot.dataManifests(table.io());
+ case DELETES:
+ return snapshot.deleteManifests(table.io());
+ default:
+ throw new IllegalArgumentException("Unknown manifest content: " + content);
+ }
+ }
+
+ private void replaceManifests(
+ Iterable deletedManifests, Iterable addedManifests) {
+ try {
+ org.apache.iceberg.RewriteManifests rewriteManifests = table.rewriteManifests();
+ deletedManifests.forEach(rewriteManifests::deleteManifest);
+ addedManifests.forEach(rewriteManifests::addManifest);
+ commit(rewriteManifests);
+
+ if (shouldStageManifests) {
+ // delete new manifests as they were rewritten before the commit
+ deleteFiles(Iterables.transform(addedManifests, ManifestFile::path));
+ }
+ } catch (CommitStateUnknownException e) {
+ // don't clean up added manifest files, because they may have been successfully committed
+ throw e;
+ } catch (Exception e) {
+ if (e instanceof CleanableFailure) {
+ deleteFiles(Iterables.transform(addedManifests, ManifestFile::path));
+ }
+
+ throw e;
+ }
+ }
+
+ private void deleteFiles(Iterable locations) {
+ Iterable files =
+ Iterables.transform(locations, location -> new FileInfo(location, MANIFEST));
+ if (table.io() instanceof SupportsBulkOperations) {
+ deleteFiles((SupportsBulkOperations) table.io(), files.iterator());
+ } else {
+ deleteFiles(
+ ThreadPools.getWorkerPool(), file -> table.io().deleteFile(file), files.iterator());
+ }
+ }
+
+ private ManifestWriterFactory manifestWriters(int specId) {
+ return new ManifestWriterFactory(
+ sparkContext().broadcast(SerializableTableWithSize.copyOf(table)),
+ formatVersion,
+ specId,
+ outputLocation,
+ // allow the actual size of manifests to be 20% higher as the estimation is not precise
+ (long) (1.2 * targetManifestSizeBytes));
+ }
+
+ private U withReusableDS(Dataset ds, Function, U> func) {
+ boolean useCaching =
+ PropertyUtil.propertyAsBoolean(options(), USE_CACHING, USE_CACHING_DEFAULT);
+ Dataset reusableDS = useCaching ? ds.cache() : ds;
+
+ try {
+ return func.apply(reusableDS);
+ } finally {
+ if (useCaching) {
+ reusableDS.unpersist(false);
+ }
+ }
+ }
+
+ /** The outcome of repairing the manifests of one content type. */
+ private static class RepairedManifests {
+ private final List repairedManifests;
+ private final List newManifests;
+ private final long repairedCount;
+
+ private RepairedManifests(
+ List repairedManifests, List newManifests, long repairedCount) {
+ this.repairedManifests = repairedManifests;
+ this.newManifests = newManifests;
+ this.repairedCount = repairedCount;
+ }
+
+ static RepairedManifests empty() {
+ return new RepairedManifests(ImmutableList.of(), ImmutableList.of(), 0L);
+ }
+
+ static RepairedManifests of(
+ List repairedManifests, List newManifests, long repairedCount) {
+ return new RepairedManifests(repairedManifests, newManifests, repairedCount);
+ }
+
+ List repairedManifests() {
+ return repairedManifests;
+ }
+
+ List newManifests() {
+ return newManifests;
+ }
+
+ long repairedCount() {
+ return repairedCount;
+ }
+ }
+
+ /**
+ * The state needed to read the statistics of a file on an executor.
+ *
+ *
The table is broadcast so that the file IO, schema and metrics config are available without
+ * being resolved for every entry.
+ */
+ private static class RepairContext implements Serializable {
+ private final Broadcast
tableBroadcast,
+ ManifestContent content,
+ int specId,
+ boolean repairColumnMetrics) {
+ this.tableBroadcast = tableBroadcast;
+ this.content = content;
+ this.specId = specId;
+ this.repairColumnMetrics = repairColumnMetrics;
+ }
+
+ Table table() {
+ return tableBroadcast.value();
+ }
+
+ FileIO io() {
+ return table().io();
+ }
+
+ ManifestContent content() {
+ return content;
+ }
+
+ boolean repairColumnMetrics() {
+ return repairColumnMetrics;
+ }
+
+ PartitionSpec spec(int id) {
+ return table().specs().get(id);
+ }
+
+ /**
+ * Returns the metrics config for the content type of the given file. A delete manifest can hold
+ * both position and equality deletes, whose configs differ, so the config is cached per content
+ * type rather than once for the whole manifest.
+ */
+ MetricsConfig metricsConfig(ContentFile> file) {
+ if (lazyMetricsConfigs == null) {
+ this.lazyMetricsConfigs = new EnumMap<>(FileContent.class);
+ }
+
+ return lazyMetricsConfigs.computeIfAbsent(
+ file.content(), fileContent -> RepairMetrics.metricsConfig(table(), fileContent));
+ }
+
+ NameMapping nameMapping() {
+ if (!nameMappingResolved) {
+ this.lazyNameMapping = RepairMetrics.nameMapping(table());
+ this.nameMappingResolved = true;
+ }
+
+ return lazyNameMapping;
+ }
+
+ SparkContentFile> newFileWrapper(Types.StructType combinedFileType, StructType sparkType) {
+ Types.StructType fileType = DataFile.getType(spec(specId).partitionType());
+ return content == ManifestContent.DATA
+ ? new SparkDataFile(combinedFileType, fileType, sparkType)
+ : new SparkDeleteFile(combinedFileType, fileType, sparkType);
+ }
+ }
+
+ /**
+ * Compares the statistics of every entry against the file the entry refers to, emitting the
+ * manifest path and file path of each entry whose statistics are incorrect.
+ */
+ private static class CheckStats implements MapPartitionsFunction> {
+ private final RepairContext context;
+
+ CheckStats(RepairContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public Iterator> call(Iterator rows) {
+ List> verdicts = Lists.newArrayList();
+ // the combined file type and the wrapper are identical for every row of the partition
+ Types.StructType combinedFileType =
+ DataFile.getType(Partitioning.partitionType(context.table()));
+ SparkContentFile> fileWrapper = null;
+
+ while (rows.hasNext()) {
+ Row row = rows.next();
+ String manifest = row.getString(0);
+ Row fileRow = row.getStruct(4);
+ if (fileWrapper == null) {
+ fileWrapper = context.newFileWrapper(combinedFileType, (StructType) fileRow.schema());
+ }
+
+ ContentFile> file = (ContentFile>) fileWrapper.wrap(fileRow);
+
+ if (!RepairMetrics.supportsMetrics(file)) {
+ continue;
+ }
+
+ String location = file.location().toString();
+
+ try {
+ InputFile input = context.io().newInputFile(location);
+ long fileSizeInBytes = input.getLength();
+ Metrics metrics =
+ RepairMetrics.readMetrics(
+ input, file, context.metricsConfig(file), context.nameMapping());
+
+ if (RepairMetrics.statsAreIncorrect(
+ file, metrics, fileSizeInBytes, context.repairColumnMetrics())) {
+ verdicts.add(new Tuple2<>(manifest, location));
+ }
+ } catch (Exception e) {
+ // the stats of a file that cannot be read are left alone, as whether they are
+ // correct cannot be told without reading it
+ LOG.warn("Skipping the entry of {} as its statistics could not be read", location, e);
+ }
+ }
+
+ return verdicts.iterator();
+ }
+ }
+
+ private static class WriteDataManifests extends WriteManifests {
+ WriteDataManifests(
+ ManifestWriterFactory writers,
+ Types.StructType combinedFileType,
+ Types.StructType fileType,
+ StructType sparkFileType,
+ RepairContext context) {
+ super(writers, combinedFileType, fileType, sparkFileType, context);
+ }
+
+ @Override
+ protected SparkContentFile newFileWrapper() {
+ return new SparkDataFile(combinedFileType(), fileType(), sparkFileType());
+ }
+
+ @Override
+ protected RollingManifestWriter newManifestWriter() {
+ return writers().newRollingManifestWriter();
+ }
+ }
+
+ private static class WriteDeleteManifests extends WriteManifests {
+ WriteDeleteManifests(
+ ManifestWriterFactory writers,
+ Types.StructType combinedFileType,
+ Types.StructType fileType,
+ StructType sparkFileType,
+ RepairContext context) {
+ super(writers, combinedFileType, fileType, sparkFileType, context);
+ }
+
+ @Override
+ protected SparkContentFile newFileWrapper() {
+ return new SparkDeleteFile(combinedFileType(), fileType(), sparkFileType());
+ }
+
+ @Override
+ protected RollingManifestWriter newManifestWriter() {
+ return writers().newRollingDeleteManifestWriter();
+ }
+ }
+
+ /**
+ * Writes the entries of the manifests being repaired, replacing the statistics of the entries
+ * that were found to be incorrect.
+ *
+ *
Entries are always written with {@link RollingManifestWriter#existing}, carrying the
+ * original snapshot id and sequence numbers so that the lineage of the files, and therefore the
+ * delete files that apply to them, is preserved.
+ */
+ private abstract static class WriteManifests>
+ implements MapPartitionsFunction {
+
+ private static final Encoder MANIFEST_ENCODER =
+ Encoders.javaSerialization(ManifestFile.class);
+
+ private final ManifestWriterFactory writers;
+ private final Types.StructType combinedFileType;
+ private final Types.StructType fileType;
+ private final StructType sparkFileType;
+ private final RepairContext context;
+
+ WriteManifests(
+ ManifestWriterFactory writers,
+ Types.StructType combinedFileType,
+ Types.StructType fileType,
+ StructType sparkFileType,
+ RepairContext context) {
+ this.writers = writers;
+ this.combinedFileType = combinedFileType;
+ this.fileType = fileType;
+ this.sparkFileType = sparkFileType;
+ this.context = context;
+ }
+
+ protected abstract SparkContentFile newFileWrapper();
+
+ protected abstract RollingManifestWriter newManifestWriter();
+
+ public Dataset apply(Dataset input) {
+ return input.mapPartitions(this, MANIFEST_ENCODER);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Iterator call(Iterator rows) throws Exception {
+ SparkContentFile fileWrapper = newFileWrapper();
+ RollingManifestWriter writer = newManifestWriter();
+
+ try {
+ while (rows.hasNext()) {
+ Row row = rows.next();
+ long snapshotId = row.getLong(1);
+ long sequenceNumber = row.getLong(2);
+ Long fileSequenceNumber = row.isNullAt(3) ? null : row.getLong(3);
+ Row fileRow = row.getStruct(4);
+ boolean repair = row.getBoolean(5);
+
+ F file = fileWrapper.wrap(fileRow);
+ if (repair) {
+ file = (F) repairStats(file);
+ }
+
+ writer.existing(file, snapshotId, sequenceNumber, fileSequenceNumber);
+ }
+ } finally {
+ writer.close();
+ }
+
+ return writer.toManifestFiles().iterator();
+ }
+
+ /** Rebuilds the file with the statistics read from the file itself. */
+ private ContentFile> repairStats(ContentFile> file) {
+ InputFile input = context.io().newInputFile(file.location());
+ long fileSizeInBytes = input.getLength();
+ Metrics metrics =
+ RepairMetrics.readMetrics(
+ input, file, context.metricsConfig(file), context.nameMapping());
+ if (!context.repairColumnMetrics()) {
+ // only the record count and file size are being repaired, so keep the stored column stats
+ metrics = RepairMetrics.recordCountOnly(file, metrics);
+ }
+
+ return RepairMetrics.withStats(file, context.spec(file.specId()), metrics, fileSizeInBytes);
+ }
+
+ protected ManifestWriterFactory writers() {
+ return writers;
+ }
+
+ protected Types.StructType combinedFileType() {
+ return combinedFileType;
+ }
+
+ protected Types.StructType fileType() {
+ return fileType;
+ }
+
+ protected StructType sparkFileType() {
+ return sparkFileType;
+ }
+ }
+
+ private static class ManifestWriterFactory implements Serializable {
+ private final Broadcast
tableBroadcast;
+ private final int formatVersion;
+ private final int specId;
+ private final String outputLocation;
+ private final long maxManifestSizeBytes;
+
+ ManifestWriterFactory(
+ Broadcast
tableBroadcast,
+ int formatVersion,
+ int specId,
+ String outputLocation,
+ long maxManifestSizeBytes) {
+ this.tableBroadcast = tableBroadcast;
+ this.formatVersion = formatVersion;
+ this.specId = specId;
+ this.outputLocation = outputLocation;
+ this.maxManifestSizeBytes = maxManifestSizeBytes;
+ }
+
+ RollingManifestWriter newRollingManifestWriter() {
+ return new RollingManifestWriter<>(this::newManifestWriter, maxManifestSizeBytes);
+ }
+
+ private ManifestWriter newManifestWriter() {
+ return ManifestFiles.write(formatVersion, spec(), newOutputFile(), null);
+ }
+
+ RollingManifestWriter newRollingDeleteManifestWriter() {
+ return new RollingManifestWriter<>(this::newDeleteManifestWriter, maxManifestSizeBytes);
+ }
+
+ private ManifestWriter newDeleteManifestWriter() {
+ return ManifestFiles.writeDeleteManifest(formatVersion, spec(), newOutputFile(), null);
+ }
+
+ private PartitionSpec spec() {
+ return table().specs().get(specId);
+ }
+
+ private OutputFile newOutputFile() {
+ return table().io().newOutputFile(newManifestLocation());
+ }
+
+ private String newManifestLocation() {
+ String fileName = FileFormat.AVRO.addExtension(NEW_MANIFEST_PREFIX + UUID.randomUUID());
+ Path filePath = new Path(outputLocation, fileName);
+ return filePath.toString();
+ }
+
+ private Table table() {
+ return tableBroadcast.value();
+ }
+ }
+}
diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java
index b7361c336a69..7e8cee437edc 100644
--- a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java
+++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java
@@ -85,6 +85,11 @@ public RewriteManifestsSparkAction rewriteManifests(Table table) {
return new RewriteManifestsSparkAction(spark, table);
}
+ @Override
+ public RepairTableSparkAction repairTable(Table table) {
+ return new RepairTableSparkAction(spark, table);
+ }
+
@Override
public ExpireSnapshotsSparkAction expireSnapshots(Table table) {
return new ExpireSnapshotsSparkAction(spark, table);
diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRepairTableAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRepairTableAction.java
new file mode 100644
index 000000000000..a04af5c2ca22
--- /dev/null
+++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRepairTableAction.java
@@ -0,0 +1,968 @@
+/*
+ * 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.spark.actions;
+
+import static org.apache.iceberg.types.Types.NestedField.optional;
+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 static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.ContentFile;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileContent;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.Files;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestWriter;
+import org.apache.iceberg.Metrics;
+import org.apache.iceberg.Parameter;
+import org.apache.iceberg.ParameterizedTestExtension;
+import org.apache.iceberg.Parameters;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.actions.RepairTable;
+import org.apache.iceberg.data.FileHelpers;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.CommitStateUnknownException;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.hadoop.HadoopTables;
+import org.apache.iceberg.io.OutputFile;
+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.spark.TestBase;
+import org.apache.iceberg.spark.source.ThreeColumnRecord;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.Pair;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+@ExtendWith(ParameterizedTestExtension.class)
+public class TestRepairTableAction extends TestBase {
+
+ private static final HadoopTables TABLES = new HadoopTables(new Configuration());
+ private static final Schema SCHEMA =
+ new Schema(
+ optional(1, "c1", Types.IntegerType.get()),
+ optional(2, "c2", Types.StringType.get()),
+ optional(3, "c3", Types.StringType.get()));
+
+ @Parameters(name = "formatVersion = {0}")
+ public static Object[] parameters() {
+ return new Object[][] {new Object[] {1}, new Object[] {2}, new Object[] {3}};
+ }
+
+ @Parameter private int formatVersion;
+
+ private String tableLocation = null;
+
+ @TempDir private Path temp;
+ @TempDir private File tableDir;
+
+ @BeforeEach
+ public void setupTableLocation() {
+ this.tableLocation = tableDir.toURI().toString();
+ }
+
+ @TestTemplate
+ public void testRepairEmptyTable() {
+ Table table = createTable(PartitionSpec.unpartitioned());
+
+ RepairTable.Result result = SparkActions.get().repairTable(table).repairFileMetrics().execute();
+
+ assertThat(result.repairedManifests()).isEmpty();
+ assertThat(result.repairedEntryCount()).isEqualTo(0);
+ }
+
+ @TestTemplate
+ public void testRepairTableWithCorrectStats() {
+ Table table = createTable(PartitionSpec.unpartitioned());
+ appendRecords(table, records(4));
+
+ Snapshot before = table.currentSnapshot();
+
+ RepairTable.Result result = SparkActions.get().repairTable(table).repairFileMetrics().execute();
+
+ assertThat(result.repairedManifests()).isEmpty();
+ assertThat(result.repairedEntryCount()).isEqualTo(0);
+
+ table.refresh();
+ assertThat(table.currentSnapshot().snapshotId())
+ .as("should not commit a snapshot when nothing is repaired")
+ .isEqualTo(before.snapshotId());
+ }
+
+ @TestTemplate
+ public void testNoRepairSelectedIsNoOp() throws IOException {
+ Table table = createTable(PartitionSpec.unpartitioned());
+ appendRecords(table, records(4));
+
+ DataFile original = onlyDataFile(table);
+ replaceManifestWithCorruptStats(table, original);
+
+ table.refresh();
+ Snapshot before = table.currentSnapshot();
+ DataFile corrupt = onlyDataFile(table);
+
+ // no repair was selected, so execute() must do nothing even though the stats are incorrect
+ RepairTable.Result result = SparkActions.get().repairTable(table).execute();
+
+ assertThat(result.repairedManifests()).isEmpty();
+ assertThat(result.repairedEntryCount()).isEqualTo(0);
+
+ table.refresh();
+ assertThat(table.currentSnapshot().snapshotId())
+ .as("a repair with nothing selected must not commit")
+ .isEqualTo(before.snapshotId());
+ assertThat(onlyDataFile(table).recordCount())
+ .as("a repair with nothing selected must leave the incorrect stats in place")
+ .isEqualTo(corrupt.recordCount());
+ }
+
+ @TestTemplate
+ public void testRepairIncorrectRecordCountAndFileSize() throws IOException {
+ Table table = createTable(PartitionSpec.unpartitioned());
+ appendRecords(table, records(4));
+
+ List