This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 2ce5cea797 [core][spark] Support collecting format table partition
statistics in MSCK REPAIR TABLE (#9297)
2ce5cea797 is described below
commit 2ce5cea797d08785be6d97e0300b3e6950304857
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Aug 21 08:54:45 2026 +0800
[core][spark] Support collecting format table partition statistics in MSCK
REPAIR TABLE (#9297)
---
docs/generated/spark_connector_configuration.html | 12 +
.../format/FormatTablePartitionStatsCollector.java | 195 ++++++++++++
.../FormatTablePartitionStatsCollectorTest.java | 346 +++++++++++++++++++++
.../apache/paimon/spark/SparkConnectorOptions.java | 17 +
.../spark/format/FormatTablePartitionRepair.java | 77 ++++-
.../PaimonFormatTablePartitionDdlExec.scala | 14 +-
.../org/apache/paimon/spark/util/OptionUtils.scala | 8 +
.../format/FormatTablePartitionRepairTest.java | 147 ++++++++-
.../CatalogManagedPartitionMsckRepairTest.scala | 44 ++-
9 files changed, 841 insertions(+), 19 deletions(-)
diff --git a/docs/generated/spark_connector_configuration.html
b/docs/generated/spark_connector_configuration.html
index 875b3d5639..71bb386148 100644
--- a/docs/generated/spark_connector_configuration.html
+++ b/docs/generated/spark_connector_configuration.html
@@ -26,6 +26,18 @@ under the License.
</tr>
</thead>
<tbody>
+ <tr>
+ <td><h5>format-table.repair.collect-statistics</h5></td>
+ <td style="word-wrap: break-word;">false</td>
+ <td>Boolean</td>
+ <td>Whether MSCK REPAIR TABLE on a Format Table also measures the
partitions it finds. Off by default: measuring lists the files inside every
partition, not only the partition directories.</td>
+ </tr>
+ <tr>
+ <td><h5>format-table.statistics.parallelism</h5></td>
+ <td style="word-wrap: break-word;">8</td>
+ <td>Integer</td>
+ <td>How many Format Table partitions MSCK REPAIR TABLE measures at
once, so that a table with many partitions does not burst listing requests at
storage.</td>
+ </tr>
<tr>
<td><h5>legacy-timestamp-mapping.enabled</h5></td>
<td style="word-wrap: break-word;">false</td>
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.java
new file mode 100644
index 0000000000..0f2323546b
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.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.paimon.table.format;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.utils.PartitionPathUtils;
+import org.apache.paimon.utils.ThreadPoolUtils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+/**
+ * Measures what the partitions of a Format Table currently hold, by listing
their directories. File
+ * count, byte size and last file creation time come from the listing; the row
count does not, since
+ * no listing opens a file. A partition holding nothing measures as an exact
zero on the file
+ * numbers, with no last file to date.
+ *
+ * <p>It lists through {@link FormatTableScan#listDataFiles}, the listing the
scan itself uses, so a
+ * measurement counts exactly the files a reader would return and committer
staging trees are pruned
+ * rather than walked. A listing failure aborts the whole collection: a
truncated listing looks
+ * exactly like a partition that lost files.
+ *
+ * <p>The result is a whole-partition measurement, so it replaces rather than
accumulates. It never
+ * decides that a partition should exist or stop existing; it measures the
ones it is given.
+ */
+public class FormatTablePartitionStatsCollector {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(FormatTablePartitionStatsCollector.class);
+
+ private final FormatTable table;
+
+ private final boolean onlyValueInPath;
+ private final int parallelism;
+
+ public FormatTablePartitionStatsCollector(FormatTable table, int
parallelism) {
+ this.table = table;
+ this.onlyValueInPath =
+ new
CoreOptions(table.options()).formatTablePartitionOnlyValueInPath();
+ this.parallelism = Math.max(1, parallelism);
+ }
+
+ /**
+ * Measures the given partitions. The result is aligned to {@code
partitions} one for one, so a
+ * caller can send it straight to the catalog alongside the same specs.
+ */
+ public List<PartitionStatistics> collect(List<Map<String, String>>
partitions) {
+ if (partitions.isEmpty()) {
+ return Collections.emptyList();
+ }
+ int threads = Math.min(parallelism, partitions.size());
+ if (threads == 1) {
+ List<PartitionStatistics> statistics = new
ArrayList<>(partitions.size());
+ for (Map<String, String> partition : partitions) {
+ statistics.add(measure(partition));
+ }
+ return statistics;
+ }
+
+ ExecutorService executor =
+ ThreadPoolUtils.createCachedThreadPool(threads,
"FORMAT-TABLE-STATS-THREAD-POOL");
+ try {
+ List<Future<PartitionStatistics>> futures = new
ArrayList<>(partitions.size());
+ for (Map<String, String> partition : partitions) {
+ futures.add(executor.submit(() -> measure(partition)));
+ }
+ List<PartitionStatistics> statistics = new
ArrayList<>(partitions.size());
+ for (Future<PartitionStatistics> future : futures) {
+ try {
+ statistics.add(future.get());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(
+ "Interrupted while measuring partitions of table "
+ table.fullName(),
+ e);
+ } catch (ExecutionException e) {
+ throw asRuntime(e.getCause());
+ }
+ }
+ return statistics;
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private PartitionStatistics measure(Map<String, String> partition) {
+ FileIO fileIO = table.fileIO();
+ Path partitionPath = partitionPath(partition);
+ List<FileStatus> files;
+ try {
+ // A missing directory surfaces here as a FileNotFoundException,
so it needs no
+ // separate existence check.
+ files = FormatTableScan.listDataFiles(fileIO, partitionPath);
+ } catch (FileNotFoundException e) {
+ // A registered partition whose directory is gone reads as empty.
+ return empty(partition);
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ String.format(
+ "Failed to list partition %s of table %s; no
statistics are written "
+ + "because a partial listing cannot be
told apart from a "
+ + "partition that lost files.",
+ partitionPath, table.fullName()),
+ e);
+ }
+
+ long fileCount = 0;
+ long fileSizeInBytes = 0;
+ long lastFileCreationTime = 0;
+ for (FileStatus file : files) {
+ fileCount++;
+ fileSizeInBytes += file.getLen();
+ lastFileCreationTime = Math.max(lastFileCreationTime,
file.getModificationTime());
+ }
+ if (fileCount == 0) {
+ return empty(partition);
+ }
+ return new PartitionStatistics(
+ partition,
+ // A listing never opens a file, so the rows a partition holds
stay unknown.
+ PartitionStatistics.UNKNOWN,
+ fileSizeInBytes,
+ fileCount,
+ lastFileCreationTime,
+ PartitionStatistics.UNKNOWN_TOTAL_BUCKETS);
+ }
+
+ /** A partition with nothing in it: the file numbers are an exact zero. */
+ private static PartitionStatistics empty(Map<String, String> partition) {
+ return new PartitionStatistics(
+ partition,
+ PartitionStatistics.UNKNOWN,
+ 0L,
+ 0L,
+ PartitionStatistics.UNKNOWN,
+ PartitionStatistics.UNKNOWN_TOTAL_BUCKETS);
+ }
+
+ private Path partitionPath(Map<String, String> partition) {
+ LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
+ for (String key : table.partitionKeys()) {
+ if (!partition.containsKey(key)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Partition %s of table %s does not give a
value for partition key "
+ + "%s, so its directory cannot be
located.",
+ partition, table.fullName(), key));
+ }
+ ordered.put(key, partition.get(key));
+ }
+ return new Path(
+ table.location(),
+ PartitionPathUtils.generatePartitionPathUtil(ordered,
onlyValueInPath));
+ }
+
+ private static RuntimeException asRuntime(Throwable cause) {
+ if (cause instanceof RuntimeException) {
+ return (RuntimeException) cause;
+ }
+ return new RuntimeException(cause);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollectorTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollectorTest.java
new file mode 100644
index 0000000000..522288c1e0
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollectorTest.java
@@ -0,0 +1,346 @@
+/*
+ * 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.paimon.table.format;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for what {@link FormatTablePartitionStatsCollector} measures, which
is what {@code ANALYZE
+ * TABLE} and a measuring {@code MSCK REPAIR} write into the catalog.
+ *
+ * <p>The staging cases are the same ones the read path has to survive: a
committer leaves trees
+ * such as {@code _temporary/}, {@code __magic_job-<id>/} and {@code
.hive-staging_*} inside the
+ * partition, and the files under them carry ordinary data file names. A
measurement that counts
+ * them reports a partition that holds more than any reader will ever return.
+ */
+class FormatTablePartitionStatsCollectorTest {
+
+ private static final Identifier TABLE =
+ Identifier.create("statistics_db", "statistics_format_table");
+ private static final String PARTITION_DIR = "year=2025/month=10";
+
+ @TempDir java.nio.file.Path tempDir;
+
+ @Test
+ void testCountsOnlyCommittedDataFiles() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ write(fileIO, tablePath, PARTITION_DIR + "/data-0.csv", 100);
+
+ PartitionStatistics measured = measure(fileIO, tablePath);
+
+ assertThat(measured.fileCount()).isEqualTo(1);
+ assertThat(measured.fileSizeInBytes()).isEqualTo(100);
+ assertThat(measured.lastFileCreationTime()).isPositive();
+ }
+
+ @Test
+ void testStagingTreesAreNotMeasured() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ write(fileIO, tablePath, PARTITION_DIR + "/data-0.csv", 100);
+ // Every one of these carries an ordinary data file name; only the
directory above says
+ // the file was never committed.
+ write(
+ fileIO,
+ tablePath,
+ PARTITION_DIR +
"/_temporary/0/_temporary/attempt_0/part-0.csv",
+ 7);
+ write(
+ fileIO,
+ tablePath,
+ PARTITION_DIR +
"/__magic_job-1/tasks/attempt_1/__base/part-1.csv",
+ 11);
+ write(fileIO, tablePath, PARTITION_DIR +
"/.hive-staging_1/-ext-10000/part-2.csv", 13);
+
+ PartitionStatistics measured = measure(fileIO, tablePath);
+
+ assertThat(measured.fileCount()).isEqualTo(1);
+ assertThat(measured.fileSizeInBytes()).isEqualTo(100);
+ }
+
+ @Test
+ void testHiddenFilesBesideTheDataAreNotMeasured() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ write(fileIO, tablePath, PARTITION_DIR + "/data-0.csv", 100);
+ write(fileIO, tablePath, PARTITION_DIR + "/_SUCCESS", 0);
+ write(fileIO, tablePath, PARTITION_DIR + "/.data-0.csv.crc", 8);
+
+ PartitionStatistics measured = measure(fileIO, tablePath);
+
+ assertThat(measured.fileCount()).isEqualTo(1);
+ assertThat(measured.fileSizeInBytes()).isEqualTo(100);
+ }
+
+ @Test
+ void testAMissingDirectoryHasNoFilesAndNoCreationTime() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+
+ PartitionStatistics measured = measure(fileIO, tablePath);
+
+ assertThat(measured.fileSizeInBytes()).isZero();
+ assertThat(measured.fileCount()).isZero();
+ // A listing never opens a file, so it has not learned that this
partition holds no rows.
+
assertThat(PartitionStatistics.isKnown(measured.recordCount())).isFalse();
+ // There is no last file, so dating one would be an invention.
+
assertThat(PartitionStatistics.isKnown(measured.lastFileCreationTime())).isFalse();
+ }
+
+ @Test
+ void testADirectoryHoldingOnlyStagedFilesMeasuresAsEmpty() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ write(
+ fileIO,
+ tablePath,
+ PARTITION_DIR +
"/_temporary/0/_temporary/attempt_0/part-0.csv",
+ 7);
+
+ PartitionStatistics measured = measure(fileIO, tablePath);
+
+ assertThat(measured.fileCount()).isZero();
+ assertThat(measured.fileSizeInBytes()).isZero();
+
assertThat(PartitionStatistics.isKnown(measured.recordCount())).isFalse();
+ }
+
+ @Test
+ void testTheValueOnlyLayoutIsMeasuredWhereItsFilesActuallyAre() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ // The same partition, laid out with values only. A measurement that
assumed key=value
+ // would look at a directory that does not exist and call the
partition empty.
+ write(fileIO, tablePath, "2025/10/data-0.csv", 64);
+
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.FILE_FORMAT.key(), "csv");
+
options.put(CoreOptions.FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH.key(),
"true");
+ PartitionStatistics measured =
+ new FormatTablePartitionStatsCollector(table(fileIO,
tablePath, options), 1)
+ .collect(Collections.singletonList(spec("2025", "10")))
+ .get(0);
+
+ assertThat(measured.fileCount()).isEqualTo(1);
+ assertThat(measured.fileSizeInBytes()).isEqualTo(64);
+ }
+
+ @Test
+ void testAValueThatHasToBeEscapedIsMeasuredWhereItsFilesActuallyAre()
throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ // The spec carries the raw value, the directory carries the escaped
one: a measurement
+ // that joined the raw value straight into the path would miss the
files entirely.
+ write(fileIO, tablePath, "year=2025/month=a%3Ab/data-0.csv", 32);
+
+ PartitionStatistics measured =
+ new FormatTablePartitionStatsCollector(table(fileIO,
tablePath), 1)
+ .collect(Collections.singletonList(spec("2025",
"a:b")))
+ .get(0);
+
+ assertThat(measured.fileCount()).isEqualTo(1);
+ assertThat(measured.fileSizeInBytes()).isEqualTo(32);
+ }
+
+ @Test
+ void testTheResultIsAlignedToTheGivenPartitions() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ write(fileIO, tablePath, "year=2025/month=10/data-0.csv", 100);
+ write(fileIO, tablePath, "year=2025/month=12/data-0.csv", 200);
+ List<Map<String, String>> partitions =
+ Arrays.asList(spec("2025", "12"), spec("2025", "11"),
spec("2025", "10"));
+
+ List<PartitionStatistics> measured =
+ new FormatTablePartitionStatsCollector(table(fileIO,
tablePath), 1)
+ .collect(partitions);
+
+ assertThat(measured).hasSize(3);
+ assertThat(measured.get(0).spec()).isEqualTo(spec("2025", "12"));
+ assertThat(measured.get(0).fileSizeInBytes()).isEqualTo(200);
+ assertThat(measured.get(1).spec()).isEqualTo(spec("2025", "11"));
+ assertThat(measured.get(1).fileCount()).isZero();
+ assertThat(measured.get(2).spec()).isEqualTo(spec("2025", "10"));
+ assertThat(measured.get(2).fileSizeInBytes()).isEqualTo(100);
+ }
+
+ @Test
+ void testParallelCollectionMeasuresTheSameThingAsSerialCollection() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ List<Map<String, String>> partitions = new ArrayList<>();
+ for (int month = 1; month <= 6; month++) {
+ String dir = String.format("year=2025/month=%02d", month);
+ write(fileIO, tablePath, dir + "/data-0.csv", month * 10);
+ write(fileIO, tablePath, dir +
"/_temporary/0/attempt_0/part-0.csv", 5);
+ partitions.add(spec("2025", String.format("%02d", month)));
+ }
+
+ List<PartitionStatistics> serial =
+ new FormatTablePartitionStatsCollector(table(fileIO,
tablePath), 1)
+ .collect(partitions);
+ List<PartitionStatistics> parallel =
+ new FormatTablePartitionStatsCollector(table(fileIO,
tablePath), 4)
+ .collect(partitions);
+
+ for (int i = 0; i < partitions.size(); i++) {
+ assertThat(parallel.get(i).spec()).isEqualTo(serial.get(i).spec());
+
assertThat(parallel.get(i).fileCount()).isEqualTo(serial.get(i).fileCount());
+ assertThat(parallel.get(i).fileSizeInBytes())
+ .isEqualTo(serial.get(i).fileSizeInBytes());
+ }
+ assertThat(serial.get(0).fileSizeInBytes()).isEqualTo(10);
+ assertThat(serial.get(5).fileSizeInBytes()).isEqualTo(60);
+ }
+
+ @Test
+ void testAListingFailureAbortsTheWholeCollection() throws Exception {
+ IOException listFailure = new IOException("injected partition LIST
failure");
+ LocalFileIO fileIO =
+ new LocalFileIO() {
+ @Override
+ public FileStatus[] listStatus(Path path) throws
IOException {
+ if ("month=11".equals(path.getName())) {
+ throw listFailure;
+ }
+ return super.listStatus(path);
+ }
+ };
+ Path tablePath = new Path(tempDir.toUri());
+ write(fileIO, tablePath, "year=2025/month=10/data-0.csv", 100);
+ write(fileIO, tablePath, "year=2025/month=11/data-0.csv", 200);
+ List<Map<String, String>> partitions =
+ Arrays.asList(spec("2025", "10"), spec("2025", "11"));
+
+ // A truncated listing cannot be told apart from a partition that lost
files, so nothing at
+ // all is reported: returning what was measured would write an exact
zero over a partition
+ // that was never read. Both the serial and the parallel path have to
abort.
+ for (int parallelism : new int[] {1, 2}) {
+ assertThatThrownBy(
+ () ->
+ new FormatTablePartitionStatsCollector(
+ table(fileIO, tablePath),
parallelism)
+ .collect(partitions))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasMessageContaining("month=11")
+ .hasCause(listFailure);
+ }
+ }
+
+ @Test
+ void testASpecMissingAPartitionKeyIsRejected() {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+
+ // Without the guard the directory name would carry the literal "null"
and the measurement
+ // would describe a path no reader ever visits.
+ assertThatThrownBy(
+ () ->
+ new
FormatTablePartitionStatsCollector(table(fileIO, tablePath), 1)
+ .collect(
+ Collections.singletonList(
+
Collections.singletonMap("year", "2025"))))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("month");
+ }
+
+ private PartitionStatistics measure(FileIO fileIO, Path tablePath) {
+ return new FormatTablePartitionStatsCollector(table(fileIO,
tablePath), 1)
+ .collect(Collections.singletonList(spec("2025", "10")))
+ .get(0);
+ }
+
+ private FormatTable table(FileIO fileIO, Path tablePath) {
+ return table(fileIO, tablePath, FormatTable.Format.CSV, "csv");
+ }
+
+ private FormatTable table(FileIO fileIO, Path tablePath, Map<String,
String> options) {
+ return table(fileIO, tablePath, FormatTable.Format.CSV, options);
+ }
+
+ private FormatTable table(
+ FileIO fileIO, Path tablePath, FormatTable.Format format, String
fileFormat) {
+ return table(
+ fileIO,
+ tablePath,
+ format,
+ Collections.singletonMap(CoreOptions.FILE_FORMAT.key(),
fileFormat));
+ }
+
+ private FormatTable table(
+ FileIO fileIO, Path tablePath, FormatTable.Format format,
Map<String, String> options) {
+ RowType rowType =
+ RowType.builder()
+ .field("year", DataTypes.STRING())
+ .field("month", DataTypes.STRING())
+ .field("id", DataTypes.INT())
+ .build();
+ return FormatTable.builder()
+ .fileIO(fileIO)
+ .identifier(TABLE)
+ .rowType(rowType)
+ .partitionKeys(Arrays.asList("year", "month"))
+ .location(tablePath.toString())
+ .format(format)
+ .options(options)
+ .build();
+ }
+
+ private static void write(FileIO fileIO, Path tablePath, String
relativePath, int bytes)
+ throws Exception {
+ Path path = new Path(tablePath, relativePath);
+ fileIO.mkdirs(path.getParent());
+ try (PositionOutputStream out = fileIO.newOutputStream(path, false)) {
+ out.write(new byte[bytes]);
+ }
+ }
+
+ private static Map<String, String> spec(String year, String month) {
+ LinkedHashMap<String, String> spec = new LinkedHashMap<>();
+ spec.put("year", year);
+ spec.put("month", month);
+ return spec;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
index 108fe12dac..4c0ec743ff 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
@@ -155,6 +155,23 @@ public class SparkConnectorOptions {
.withDescription(
"Whether to allow full scan when reading a
partitioned table.");
+ public static final ConfigOption<Boolean>
FORMAT_TABLE_REPAIR_COLLECT_STATISTICS =
+ key("format-table.repair.collect-statistics")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether MSCK REPAIR TABLE on a Format Table also
measures the partitions it "
+ + "finds. Off by default: measuring lists
the files inside every partition, "
+ + "not only the partition directories.");
+
+ public static final ConfigOption<Integer>
FORMAT_TABLE_STATISTICS_PARALLELISM =
+ key("format-table.statistics.parallelism")
+ .intType()
+ .defaultValue(8)
+ .withDescription(
+ "How many Format Table partitions MSCK REPAIR
TABLE measures at once, so that a "
+ + "table with many partitions does not
burst listing requests at storage.");
+
public static final ConfigOption<Boolean>
SOURCE_SPLIT_TARGET_SIZE_WITH_COLUMN_PRUNING =
key("source.split.target-size-with-column-pruning")
.booleanType()
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
index d77968f248..d213f4d86b 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
@@ -23,10 +23,13 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.format.FormatTablePartitionManager;
+import org.apache.paimon.table.format.FormatTablePartitionStatsCollector;
import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.PartitionPathUtils;
import org.apache.paimon.utils.Preconditions;
+import javax.annotation.Nullable;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -52,6 +55,23 @@ public class FormatTablePartitionRepair {
*/
public static int repair(
PaimonFormatTable sparkTable, boolean addPartitions, boolean
dropPartitions) {
+ return repair(sparkTable, addPartitions, dropPartitions, null);
+ }
+
+ /**
+ * Repair the partition metadata of a Format Table with catalog-managed
partitions, optionally
+ * measuring the partitions it finds and reporting their statistics.
+ *
+ * <p>When measuring, every partition found on the filesystem is measured,
not only the newly
+ * registered ones: a repair is exactly the moment the catalog numbers are
known to be behind.
+ *
+ * @param statsCollector measures the partitions, or null to only
reconcile the registration
+ */
+ public static int repair(
+ PaimonFormatTable sparkTable,
+ boolean addPartitions,
+ boolean dropPartitions,
+ @Nullable FormatTablePartitionStatsCollector statsCollector) {
Preconditions.checkArgument(
addPartitions || dropPartitions,
"MSCK REPAIR TABLE must enable ADD and/or DROP partitions");
@@ -67,15 +87,14 @@ public class FormatTablePartitionRepair {
listFilesystemPartitionSpecs(formatTable),
formatTable.partitionKeys(),
addPartitions,
- dropPartitions);
+ dropPartitions,
+ statsCollector);
}
private static List<Map<String, String>>
listFilesystemPartitionSpecs(FormatTable formatTable) {
- // Discover partitions from the raw directory names rather than
through the table scan:
- // the scan casts each value to its column type and back (e.g.
month=01 -> 1), producing
- // specs that can no longer round-trip to the real directory. The
write path registers the
- // raw directory value, so a repair must diff against the same raw
values to avoid
- // spuriously adding/dropping partition metadata.
+ // Raw directory names rather than the table scan: the scan casts each
value to its column
+ // type and back (month=01 -> 1), producing specs that no longer name
the real directory,
+ // while the write path registers the raw value.
boolean onlyValueInPath =
new
CoreOptions(formatTable.options()).formatTablePartitionOnlyValueInPath();
List<Pair<LinkedHashMap<String, String>, Path>> found =
@@ -96,11 +115,9 @@ public class FormatTablePartitionRepair {
/**
* Diff the filesystem partition set against the catalog registration set
and apply the
* requested actions. ADD registers "directory exists but unregistered";
DROP is metadata-only
- * cleanup of "registered but directory missing". Scan-completeness guard:
the filesystem
- * listing that feeds {@code filesystemPartitions} ({@link
- * PartitionPathUtils#searchPartSpecAndPaths}) fails on any mid-scan LIST
error instead of
- * returning a truncated set, so a DROP diff can only be produced from a
complete listing and a
- * transient failure never deregisters partitions that still exist.
+ * cleanup of "registered but directory missing". {@link
+ * PartitionPathUtils#searchPartSpecAndPaths} fails on a mid-scan LIST
error rather than
+ * returning a truncated set, so a transient failure never deregisters
partitions that exist.
*/
static int apply(
FormatTablePartitionManager partitionManager,
@@ -108,6 +125,22 @@ public class FormatTablePartitionRepair {
List<String> partitionKeys,
boolean addPartitions,
boolean dropPartitions) {
+ return apply(
+ partitionManager,
+ filesystemPartitions,
+ partitionKeys,
+ addPartitions,
+ dropPartitions,
+ null);
+ }
+
+ static int apply(
+ FormatTablePartitionManager partitionManager,
+ List<Map<String, String>> filesystemPartitions,
+ List<String> partitionKeys,
+ boolean addPartitions,
+ boolean dropPartitions,
+ @Nullable FormatTablePartitionStatsCollector statsCollector) {
Set<Map<String, String>> registeredPartitions = new HashSet<>();
for (Partition partition :
partitionManager.listPartitions(Collections.<String,
String>emptyMap(), null)) {
@@ -135,11 +168,25 @@ public class FormatTablePartitionRepair {
sortByCanonicalPath(dropDiff, partitionKeys);
}
- // A first repair of a pre-existing table can discover far more
partitions than any regular
- // write. Splitting such a diff into per-request batches is the
partition catalog's job;
- // registration is an idempotent upsert and unregistration ignores
missing partitions, so a
+ // A first repair can discover far more partitions than any regular
write. Splitting the
+ // diff into requests is the partition catalog's job; both halves are
idempotent, so a
// mid-way failure leaves a state a rerun converges from.
- if (!addDiff.isEmpty()) {
+ if (statsCollector != null) {
+ // Every partition that ends up registered with a directory behind
it, not only the
+ // newly added ones: numbers for partitions written outside Paimon
are what a repair
+ // exists to correct. Without ADD it stays inside the already
registered set.
+ List<Map<String, String>> measured = new ArrayList<>();
+ for (Map<String, String> partition : filesystemPartitions) {
+ if (addPartitions || registeredPartitions.contains(partition))
{
+ measured.add(partition);
+ }
+ }
+ sortByCanonicalPath(measured, partitionKeys);
+ if (!measured.isEmpty()) {
+ partitionManager.createPartitions(
+ measured, true, statsCollector.collect(measured),
true);
+ }
+ } else if (!addDiff.isEmpty()) {
partitionManager.createPartitions(addDiff, true);
}
if (!dropDiff.isEmpty()) {
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFormatTablePartitionDdlExec.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFormatTablePartitionDdlExec.scala
index 45c925b446..84cd80d12d 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFormatTablePartitionDdlExec.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFormatTablePartitionDdlExec.scala
@@ -20,6 +20,8 @@ package org.apache.paimon.spark.execution
import org.apache.paimon.CoreOptions
import org.apache.paimon.spark.format.{FormatTablePartitionRepair,
PaimonFormatTable}
+import org.apache.paimon.spark.util.OptionUtils
+import org.apache.paimon.table.format.FormatTablePartitionStatsCollector
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.analysis.{NoSuchPartitionsException,
ResolvedPartitionSpec}
@@ -189,8 +191,18 @@ case class PaimonRepairFormatTablePartitionsExec(
extends LeafV2CommandExec {
override protected def run(): Seq[InternalRow] = {
+ // A repair stops at the numbers a directory listing already gives; a
record count needs the
+ // file footers, which no listing opens.
+ val statsCollector =
+ if (OptionUtils.formatTableRepairCollectStatistics()) {
+ new FormatTablePartitionStatsCollector(
+ table.table,
+ OptionUtils.formatTableStatisticsParallelism())
+ } else {
+ null
+ }
PaimonFormatTablePartitionDdlExec.refreshingCache(refreshCache) {
- FormatTablePartitionRepair.repair(table, addPartitions, dropPartitions)
+ FormatTablePartitionRepair.repair(table, addPartitions, dropPartitions,
statsCollector)
}
Seq.empty
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
index 10d403248b..1649a57ead 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
@@ -146,6 +146,14 @@ object OptionUtils extends SQLConfHelper with Logging {
getOptionString(SparkConnectorOptions.SOURCE_SPLIT_TARGET_SIZE_WITH_COLUMN_PRUNING).toBoolean
}
+ def formatTableRepairCollectStatistics(): Boolean = {
+
getOptionString(SparkConnectorOptions.FORMAT_TABLE_REPAIR_COLLECT_STATISTICS).toBoolean
+ }
+
+ def formatTableStatisticsParallelism(): Int = {
+
getOptionString(SparkConnectorOptions.FORMAT_TABLE_STATISTICS_PARALLELISM).toInt
+ }
+
private def mergeSQLConf(extraOptions: JMap[String, String]): JMap[String,
String] = {
val mergedOptions = new JHashMap[String, String](
conf.getAllConfs
diff --git
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
index 033285c46e..1ee208f2b9 100644
---
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
+++
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
@@ -29,6 +29,7 @@ import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.format.FormatTablePartitionManager;
+import org.apache.paimon.table.format.FormatTablePartitionStatsCollector;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
@@ -38,6 +39,7 @@ import org.junit.jupiter.api.io.TempDir;
import javax.annotation.Nullable;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
@@ -368,6 +370,137 @@ class FormatTablePartitionRepairTest {
assertThat(catalog.droppedPartitions).isEmpty();
}
+ @Test
+ void repairMeasuresEveryPartitionOnDiskAndReplacesTheirStatistics() throws
Exception {
+ java.nio.file.Path known =
Files.createDirectories(tempDir.resolve("dt=20260701"));
+ Files.write(known.resolve("data.csv"), Arrays.asList("1", "2"),
StandardCharsets.UTF_8);
+ java.nio.file.Path fresh =
Files.createDirectories(tempDir.resolve("dt=20260702"));
+ Files.write(
+ fresh.resolve("data.csv"), Collections.singletonList("3"),
StandardCharsets.UTF_8);
+
+ RecordingPartitionManager catalog = new RecordingPartitionManager();
+ catalog.register(Collections.singletonList(spec("dt", "20260701")));
+ FormatTable table = formatTable(tempDir.toUri().toString(), catalog);
+ PaimonFormatTable sparkTable = new PaimonFormatTable(table);
+
+ int applied =
+ FormatTablePartitionRepair.repair(
+ sparkTable, true, false, new
FormatTablePartitionStatsCollector(table, 1));
+
+ // Only one partition was missing from the registration, but a repair
that measures corrects
+ // the numbers of the already-registered one too — being behind is why
it is running.
+ assertThat(applied).isEqualTo(1);
+ assertThat(catalog.createdPartitions)
+ .containsExactly(Arrays.asList(spec("dt", "20260701"),
spec("dt", "20260702")));
+ assertThat(catalog.replaceFlags).containsExactly(true);
+ // One measurement per spec, in the same order: the catalog reads the
two lists side by
+ // side, so a short or reordered statistics list would describe the
wrong partitions.
+ List<PartitionStatistics> reported = catalog.reportedStatistics.get(0);
+ assertThat(reported).hasSize(2);
+ assertThat(reported.get(0).spec()).isEqualTo(spec("dt", "20260701"));
+ assertThat(reported.get(0).fileCount()).isEqualTo(1);
+ assertThat(reported.get(0).fileSizeInBytes()).isPositive();
+ assertThat(reported.get(0).lastFileCreationTime()).isPositive();
+ // CSV carries no footer, so the row count is unknown rather than a
number nobody measured.
+
assertThat(PartitionStatistics.isKnown(reported.get(0).recordCount())).isFalse();
+ assertThat(reported.get(1).spec()).isEqualTo(spec("dt", "20260702"));
+ assertThat(reported.get(1).fileCount()).isEqualTo(1);
+ assertThat(reported.get(1).fileSizeInBytes()).isPositive();
+ assertThat(catalog.droppedPartitions).isEmpty();
+ }
+
+ @Test
+ void repairWritesNothingWhenMeasuringAPartitionFailsToList() throws
Exception {
+ Files.write(
+
Files.createDirectories(tempDir.resolve("dt=20260701")).resolve("data.csv"),
+ Collections.singletonList("1"),
+ StandardCharsets.UTF_8);
+ Files.createDirectories(tempDir.resolve("dt=20260702"));
+
+ IOException listFailure = new IOException("injected partition
measurement LIST failure");
+ FileIO fileIO =
+ new LocalFileIO() {
+ @Override
+ public FileStatus[] listStatus(Path path) throws
IOException {
+ if ("dt=20260702".equals(path.getName())) {
+ throw listFailure;
+ }
+ return super.listStatus(path);
+ }
+ };
+
+ RecordingPartitionManager catalog = new RecordingPartitionManager();
+ FormatTable table = formatTable(fileIO, tempDir.toUri().toString(),
false, catalog);
+ PaimonFormatTable sparkTable = new PaimonFormatTable(table);
+
+ assertThatThrownBy(
+ () ->
+ FormatTablePartitionRepair.repair(
+ sparkTable,
+ true,
+ false,
+ new
FormatTablePartitionStatsCollector(table, 1)))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasCause(listFailure);
+ // The partition that did list measured fine, but half a measurement
written as if it were
+ // the whole one is the corruption the abort exists to prevent:
nothing reaches the catalog,
+ // and the registration the repair would have added is not applied
either.
+ assertThat(catalog.createdPartitions).isEmpty();
+ assertThat(catalog.reportedStatistics).isEmpty();
+ assertThat(catalog.droppedPartitions).isEmpty();
+ }
+
+ @Test
+ void repairWithoutAddNeverRegistersAPartitionJustToMeasureIt() throws
Exception {
+ java.nio.file.Path registeredDirectory =
+ Files.createDirectories(tempDir.resolve("dt=20260701"));
+ Files.write(
+ registeredDirectory.resolve("data.csv"),
+ Collections.singletonList("1"),
+ StandardCharsets.UTF_8);
+ java.nio.file.Path unregisteredDirectory =
+ Files.createDirectories(tempDir.resolve("dt=20260702"));
+ Files.write(
+ unregisteredDirectory.resolve("data.csv"),
+ Collections.singletonList("2"),
+ StandardCharsets.UTF_8);
+
+ RecordingPartitionManager catalog = new RecordingPartitionManager();
+ catalog.register(Collections.singletonList(spec("dt", "20260701")));
+ FormatTable table = formatTable(tempDir.toUri().toString(), catalog);
+ PaimonFormatTable sparkTable = new PaimonFormatTable(table);
+
+ FormatTablePartitionRepair.repair(
+ sparkTable, false, true, new
FormatTablePartitionStatsCollector(table, 1));
+
+ // MSCK DROP PARTITIONS asked for no registrations; measuring must not
smuggle one in.
+ assertThat(catalog.createdPartitions)
+ .containsExactly(Collections.singletonList(spec("dt",
"20260701")));
+ }
+
+ @Test
+ void repairWithoutMeasuringKeepsTheSpecOnlyRegistration() throws Exception
{
+ java.nio.file.Path partitionDirectory =
+ Files.createDirectories(tempDir.resolve("dt=20260701"));
+ Files.write(
+ partitionDirectory.resolve("data.csv"),
+ Collections.singletonList("1"),
+ StandardCharsets.UTF_8);
+
+ RecordingPartitionManager catalog = new RecordingPartitionManager();
+ PaimonFormatTable sparkTable =
+ new PaimonFormatTable(formatTable(tempDir.toUri().toString(),
catalog));
+
+ FormatTablePartitionRepair.repair(sparkTable, true, false);
+
+ assertThat(catalog.createdPartitions)
+ .containsExactly(Collections.singletonList(spec("dt",
"20260701")));
+ // Registering without measuring is one call that carries no
statistics, not the absence of
+ // a call: the repair still has to register what it found.
+ assertThat(catalog.reportedStatistics).hasSize(1).containsOnlyNulls();
+ assertThat(catalog.replaceFlags).containsExactly(false);
+ }
+
private static Map<String, String> spec(String key, String value) {
Map<String, String> spec = new LinkedHashMap<>();
spec.put(key, value);
@@ -380,13 +513,21 @@ class FormatTablePartitionRepairTest {
private static FormatTable formatTable(
String location, boolean onlyValueInPath,
FormatTablePartitionManager catalog) {
+ return formatTable(LocalFileIO.create(), location, onlyValueInPath,
catalog);
+ }
+
+ private static FormatTable formatTable(
+ FileIO fileIO,
+ String location,
+ boolean onlyValueInPath,
+ FormatTablePartitionManager catalog) {
RowType rowType =
RowType.builder()
.field("id", DataTypes.INT())
.field("dt", DataTypes.STRING())
.build();
return build(
- LocalFileIO.create(),
+ fileIO,
location,
rowType,
Collections.singletonList("dt"),
@@ -439,6 +580,8 @@ class FormatTablePartitionRepairTest {
private final List<List<Map<String, String>>> createdPartitions = new
ArrayList<>();
private final List<Boolean> createIgnoreFlags = new ArrayList<>();
private final List<List<Map<String, String>>> droppedPartitions = new
ArrayList<>();
+ private final List<List<PartitionStatistics>> reportedStatistics = new
ArrayList<>();
+ private final List<Boolean> replaceFlags = new ArrayList<>();
private void register(List<Map<String, String>> partitions) {
registered.addAll(partitions);
@@ -452,6 +595,8 @@ class FormatTablePartitionRepairTest {
boolean replaceStatistics) {
createdPartitions.add(new ArrayList<>(partitions));
createIgnoreFlags.add(ignoreIfExists);
+ reportedStatistics.add(statistics == null ? null : new
ArrayList<>(statistics));
+ replaceFlags.add(replaceStatistics);
}
@Override
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
index 586099b73b..bee92ed562 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
@@ -22,7 +22,7 @@ import org.apache.paimon.catalog.Identifier
import org.apache.paimon.fs.Path
import org.apache.paimon.partition.{Partition, PartitionStatistics}
import org.apache.paimon.predicate.Predicate
-import org.apache.paimon.spark.{PaimonSparkTestWithRestCatalogBase,
SparkCatalog}
+import org.apache.paimon.spark.{PaimonSparkTestWithRestCatalogBase,
SparkCatalog, SparkConnectorOptions}
import org.apache.paimon.spark.execution.PaimonRepairFormatTablePartitionsExec
import org.apache.paimon.spark.format.PaimonFormatTable
import org.apache.paimon.table.FormatTable
@@ -415,6 +415,46 @@ class CatalogManagedPartitionMsckRepairTest extends
PaimonSparkTestWithRestCatal
}
}
+ test("MSCK leaves statistics unknown until it is asked to measure") {
+ val tableName = "msck_statistics"
+ val partition = "20260721"
+
+ withTable(tableName) {
+ createFormatTableWithCatalogManagedPartitions(tableName)
+ writeCsvPartition(tableName, partition, 21, "measured")
+
+ executeCatalogManagedRepair(s"MSCK REPAIR TABLE
paimon.$dbName0.$tableName")
+ // Registering a partition measures nothing about it, so every statistic
stays unknown —
+ // an exact zero here would be a number nobody took.
+ val registered = statisticsOf(tableName, partition)
+ assert(!PartitionStatistics.isKnown(registered.fileCount()),
registered.toString)
+ assert(!PartitionStatistics.isKnown(registered.fileSizeInBytes()),
registered.toString)
+ assert(!PartitionStatistics.isKnown(registered.recordCount()),
registered.toString)
+
+ val collectStatistics =
+
s"spark.paimon.${SparkConnectorOptions.FORMAT_TABLE_REPAIR_COLLECT_STATISTICS.key()}"
+ withSQLConf(collectStatistics -> "true") {
+ executeCatalogManagedRepair(s"MSCK REPAIR TABLE
paimon.$dbName0.$tableName")
+ }
+
+ val measured = statisticsOf(tableName, partition)
+ assert(measured.fileCount() == 1L, measured.toString)
+ assert(measured.fileSizeInBytes() > 0L, measured.toString)
+ assert(measured.lastFileCreationTime() > 0L, measured.toString)
+ // A repair only lists; CSV carries no footer, so the row count is still
nobody's measurement.
+ assert(!PartitionStatistics.isKnown(measured.recordCount()),
measured.toString)
+ // Measuring is not a way to change which partitions exist.
+ assertPartitionState(tableName, Set(partition))
+ }
+ }
+
+ private def statisticsOf(tableName: String, partition: String): Partition =
+ paimonCatalog
+ .listPartitions(tableIdentifier(tableName))
+ .asScala
+ .find(_.spec().get("dt") == partition)
+ .getOrElse(fail(s"partition dt=$partition of $tableName is not
registered"))
+
private def createFormatTableWithCatalogManagedPartitions(tableName:
String): Unit =
sql(s"""CREATE TABLE $tableName (id INT, payload STRING, dt STRING)
|USING CSV
@@ -648,7 +688,7 @@ private[sql] class
FaultInjectingFormatTablePartitionManager(delegate: FormatTab
ignoreIfExists: Boolean,
statistics: JList[PartitionStatistics],
replaceStatistics: Boolean): Unit = {
- delegate.createPartitions(partitions, ignoreIfExists)
+ delegate.createPartitions(partitions, ignoreIfExists, statistics,
replaceStatistics)
MsckFaultInjection.createCalls += 1
}