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 d15d250cf6 [core][spark] Support remove_unexisting_manifests procedure
(#10083)
d15d250cf6 is described below
commit d15d250cf63e601b448318392158f1531b30193e
Author: Wenchao Wu <[email protected]>
AuthorDate: Thu Sep 24 14:44:43 2026 +0800
[core][spark] Support remove_unexisting_manifests procedure (#10083)
---
docs/docs/spark/procedures.md | 1 +
docs/docs/spark/procedures/maintenance.md | 16 ++
.../operation/RemoveUnexistingManifests.java | 78 +++++---
.../operation/RemoveUnexistingManifestsTest.java | 213 +++++++++++++++++++++
.../action/RemoveUnexistingManifestsAction.java | 74 +------
.../org/apache/paimon/spark/SparkProcedures.java | 3 +
.../RemoveUnexistingManifestsProcedure.java | 102 ++++++++++
.../RemoveUnexistingManifestsProcedureTest.scala | 159 +++++++++++++++
8 files changed, 547 insertions(+), 99 deletions(-)
diff --git a/docs/docs/spark/procedures.md b/docs/docs/spark/procedures.md
index 045f4bce92..2048c49b07 100644
--- a/docs/docs/spark/procedures.md
+++ b/docs/docs/spark/procedures.md
@@ -69,6 +69,7 @@ Choose a group, then use the page contents to jump to a
procedure:
[`remove_orphan_files`](./procedures/maintenance#remove_orphan_files),
[`remove_orphan_blobs`](./procedures/maintenance#remove_orphan_blobs),
[`remove_unexisting_files`](./procedures/maintenance#remove_unexisting_files),
+[`remove_unexisting_manifests`](./procedures/maintenance#remove_unexisting_manifests),
[`purge_files`](./procedures/maintenance#purge_files),
[`repair`](./procedures/maintenance#repair),
[`repair_earliest_snapshot`](./procedures/maintenance#repair_earliest_snapshot)
diff --git a/docs/docs/spark/procedures/maintenance.md
b/docs/docs/spark/procedures/maintenance.md
index 1b2a77a507..366dd13e53 100644
--- a/docs/docs/spark/procedures/maintenance.md
+++ b/docs/docs/spark/procedures/maintenance.md
@@ -317,6 +317,22 @@ CALL sys.remove_unexisting_files(table => 'mydb.myt');
CALL sys.remove_unexisting_files(table => 'mydb.myt', dry_run => true);
```
+## remove_unexisting_manifests
+
+Remove missing manifest files from the latest snapshot's manifest list and
commit a replacement snapshot.
+
+This procedure may cause data loss when used outside of the documented repair
cases.
+
+**Arguments**
+
+- `table` (`STRING`, required): the target table identifier. To repair a
branch, backtick-quote the table name so `$` stays inside the identifier.
+
+```sql
+CALL sys.remove_unexisting_manifests(table => 'mydb.myt');
+
+CALL sys.remove_unexisting_manifests(table => 'mydb.`myt$branch_rt`');
+```
+
## purge_files
Clear table with purge files.
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
b/paimon-core/src/main/java/org/apache/paimon/operation/RemoveUnexistingManifests.java
similarity index 61%
copy from
paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
copy to
paimon-core/src/main/java/org/apache/paimon/operation/RemoveUnexistingManifests.java
index e0fe8ac870..d2ed729581 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/RemoveUnexistingManifests.java
@@ -16,17 +16,16 @@
* limitations under the License.
*/
-package org.apache.paimon.flink.action;
+package org.apache.paimon.operation;
import org.apache.paimon.Snapshot;
-import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.manifest.FileEntry;
+import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.manifest.ManifestFileMeta;
import org.apache.paimon.manifest.ManifestList;
-import org.apache.paimon.operation.FileStoreCommitImpl;
-import org.apache.paimon.operation.ManifestsReader;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.source.ScanMode;
import org.apache.paimon.utils.FileStorePathFactory;
@@ -37,36 +36,37 @@ import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
-import java.util.Map;
+import java.util.Set;
import java.util.UUID;
-import static org.apache.paimon.manifest.ManifestEntry.recordCount;
-
-/** Action to remove the un-existing manifest file. */
-public class RemoveUnexistingManifestsAction extends ActionBase implements
LocalAction {
+/**
+ * Remove unexisting manifest files from the latest snapshot's manifest list.
+ *
+ * <p>Note that callers are on their own risk using this, which may cause data
loss when used
+ * outside the documented repair cases.
+ */
+public class RemoveUnexistingManifests {
- private static final Logger LOG =
- LoggerFactory.getLogger(RemoveUnexistingManifestsAction.class);
+ private static final Logger LOG =
LoggerFactory.getLogger(RemoveUnexistingManifests.class);
- private final String databaseName;
- private final String tableName;
+ private final FileStoreTable table;
- public RemoveUnexistingManifestsAction(
- String databaseName, String tableName, Map<String, String>
catalogConfig) {
- super(catalogConfig);
- this.databaseName = databaseName;
- this.tableName = tableName;
+ public RemoveUnexistingManifests(FileStoreTable table) {
+ this.table = table;
}
- @Override
- public void executeLocally() throws Exception {
- Identifier identifier = new Identifier(databaseName, tableName);
- FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
+ /**
+ * Drop missing manifest files from the latest snapshot and commit a
replacement snapshot.
+ *
+ * @return {@code true} if a repair snapshot was committed
+ */
+ public boolean execute() {
FileIO fileIO = table.fileIO();
Snapshot latest = table.snapshotManager().latestSnapshot();
if (latest == null) {
- return;
+ return false;
}
ManifestsReader manifestsReader =
table.store().newScan().manifestsReader();
@@ -82,22 +82,22 @@ public class RemoveUnexistingManifestsAction extends
ActionBase implements Local
Path path = pathFactory.toManifestFilePath(meta.fileName());
if (!fileIO.exists(path)) {
brokenManifestFile = true;
- LOG.warn("Drop manifest file: " + meta.fileName());
+ LOG.warn("Drop manifest file: {}", meta.fileName());
} else {
baseManifestEntries.addAll(table.store().newScan().readManifest(meta));
existingManifestFiles.add(meta);
}
} catch (Exception e) {
- throw new RuntimeException("Exception happens", e);
+ throw new RuntimeException("Failed to read manifest file " +
meta.fileName(), e);
}
}
if (!brokenManifestFile) {
- return;
+ return false;
}
ManifestList manifestList =
table.store().manifestListFactory().create();
- long totalRecordCount = recordCount(baseManifestEntries);
+ long totalRecordCount = visibleRecordCount(baseManifestEntries);
Pair<String, Long> baseManifestList =
manifestList.write(existingManifestFiles);
Pair<String, Long> deltaManifestList =
manifestList.write(Collections.emptyList());
@@ -112,5 +112,29 @@ public class RemoveUnexistingManifestsAction extends
ActionBase implements Local
"Failed, snapshot conflict, maybe multiple jobs is
running to commit snapshots.");
}
}
+ return true;
+ }
+
+ /**
+ * Rows a scan can still read from the remaining manifests.
+ *
+ * <p>A scan keeps an ADD entry only when no remaining manifest deletes
that file. Summing every
+ * entry's row count counts DELETE entries as extra rows. Subtracting
DELETE row counts
+ * under-counts when the matching ADD was in a manifest that has been
dropped.
+ */
+ private static long visibleRecordCount(List<ManifestEntry> entries) {
+ Set<FileEntry.Identifier> deleted = new HashSet<>();
+ for (ManifestEntry entry : entries) {
+ if (entry.kind() == FileKind.DELETE) {
+ deleted.add(entry.identifier());
+ }
+ }
+ long total = 0L;
+ for (ManifestEntry entry : entries) {
+ if (entry.kind() == FileKind.ADD &&
!deleted.contains(entry.identifier())) {
+ total += entry.file().rowCount();
+ }
+ }
+ return total;
}
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/RemoveUnexistingManifestsTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/RemoveUnexistingManifestsTest.java
new file mode 100644
index 0000000000..abd8fec4cd
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/RemoveUnexistingManifestsTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.operation;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.catalog.CatalogFactory;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.ManifestFileMeta;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.ScanMode;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.TraceableFileIO;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+import static org.apache.paimon.manifest.ManifestEntry.recordCount;
+import static org.apache.paimon.manifest.ManifestEntry.recordCountAdd;
+import static org.apache.paimon.manifest.ManifestEntry.recordCountDelete;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link RemoveUnexistingManifests}. */
+public class RemoveUnexistingManifestsTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ private Catalog catalog;
+ private FileStoreTable table;
+
+ @BeforeEach
+ public void beforeEach() throws Exception {
+ Path warehouse = new Path(TraceableFileIO.SCHEME + "://" +
tempDir.toString());
+ catalog =
CatalogFactory.createCatalog(CatalogContext.create(warehouse));
+ Identifier identifier = new Identifier("default", "T");
+ catalog.createDatabase(identifier.getDatabaseName(), true);
+ Schema schema =
+ Schema.newBuilder()
+ .column("k", DataTypes.INT())
+ .column("v", DataTypes.STRING())
+ .primaryKey("k")
+ .option("bucket", "1")
+ .option("manifest.target-file-size", "1 B")
+ .build();
+ catalog.createTable(identifier, schema, true);
+ table = (FileStoreTable) catalog.getTable(identifier);
+ }
+
+ @AfterEach
+ public void afterEach() throws IOException {
+ Predicate<Path> pathPredicate = path ->
path.toString().contains(tempDir.toString());
+ assertThat(TraceableFileIO.openInputStreams(pathPredicate)).isEmpty();
+ assertThat(TraceableFileIO.openOutputStreams(pathPredicate)).isEmpty();
+ }
+
+ @Test
+ public void testNoSnapshotIsNoOp() {
+ assertThat(new RemoveUnexistingManifests(table).execute()).isFalse();
+ assertThat(table.snapshotManager().latestSnapshot()).isNull();
+ }
+
+ @Test
+ public void testExistingManifestsAreNoOp() throws Exception {
+ commit(GenericRow.of(1, BinaryString.fromString("a")));
+ long snapshotId = table.snapshotManager().latestSnapshot().id();
+
+ assertThat(new RemoveUnexistingManifests(table).execute()).isFalse();
+
assertThat(table.snapshotManager().latestSnapshot().id()).isEqualTo(snapshotId);
+ assertThat(readCount()).isEqualTo(1);
+ }
+
+ @Test
+ public void
testTotalRecordCountMatchesVisibleRowsAfterDroppingDeletedAdd() throws
Exception {
+ commit(GenericRow.of(1, BinaryString.fromString("a")));
+ commit(GenericRow.of(2, BinaryString.fromString("b")));
+ compact();
+
+ List<ManifestFileMeta> manifests = manifests();
+ List<List<ManifestEntry>> entries = new ArrayList<>();
+ for (ManifestFileMeta meta : manifests) {
+ entries.add(table.store().newScan().readManifest(meta));
+ }
+ int dropIndex = indexOfAddDeletedElsewhere(entries);
+ assertThat(dropIndex).isGreaterThanOrEqualTo(0);
+
+ List<ManifestEntry> remaining = new ArrayList<>();
+ for (int i = 0; i < entries.size(); i++) {
+ if (i != dropIndex) {
+ remaining.addAll(entries.get(i));
+ }
+ }
+ long summedRowCount = recordCount(remaining);
+ long addedMinusDeleted = recordCountAdd(remaining) -
recordCountDelete(remaining);
+
+ Path path =
+
table.store().pathFactory().toManifestFilePath(manifests.get(dropIndex).fileName());
+ assertThat(table.fileIO().delete(path, false)).isTrue();
+
+ long snapshotId = table.snapshotManager().latestSnapshot().id();
+ assertThat(new RemoveUnexistingManifests(table).execute()).isTrue();
+
+ table = (FileStoreTable) catalog.getTable(new Identifier("default",
"T"));
+ Snapshot latest = table.snapshotManager().latestSnapshot();
+ long visibleRows = readCount();
+ assertThat(latest.id()).isEqualTo(snapshotId + 1);
+ assertThat(latest.totalRecordCount()).isEqualTo(visibleRows);
+ assertThat(summedRowCount).isNotEqualTo(visibleRows);
+ assertThat(addedMinusDeleted).isNotEqualTo(visibleRows);
+
assertThat(manifestNames()).doesNotContain(manifests.get(dropIndex).fileName());
+ }
+
+ private int indexOfAddDeletedElsewhere(List<List<ManifestEntry>> entries) {
+ for (int i = 0; i < entries.size(); i++) {
+ for (ManifestEntry entry : entries.get(i)) {
+ if (entry.kind() != FileKind.ADD) {
+ continue;
+ }
+ for (int j = 0; j < entries.size(); j++) {
+ if (i == j) {
+ continue;
+ }
+ for (ManifestEntry other : entries.get(j)) {
+ if (other.kind() == FileKind.DELETE
+ &&
other.identifier().equals(entry.identifier())) {
+ return i;
+ }
+ }
+ }
+ }
+ }
+ return -1;
+ }
+
+ private List<ManifestFileMeta> manifests() {
+ return table.store()
+ .newScan()
+ .manifestsReader()
+ .read(table.snapshotManager().latestSnapshot(), ScanMode.ALL)
+ .allManifests;
+ }
+
+ private List<String> manifestNames() {
+ List<String> names = new ArrayList<>();
+ for (ManifestFileMeta meta : manifests()) {
+ names.add(meta.fileName());
+ }
+ return names;
+ }
+
+ private long readCount() throws Exception {
+ ReadBuilder readBuilder = table.newReadBuilder();
+ try (RecordReader<?> reader =
+
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+ long[] count = new long[1];
+ reader.forEachRemaining(row -> count[0]++);
+ return count[0];
+ }
+ }
+
+ private void commit(GenericRow row) throws Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ write.write(row);
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ private void compact() throws Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ write.compact(BinaryRow.EMPTY_ROW, 0, true);
+ commit.commit(write.prepareCommit());
+ }
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
index e0fe8ac870..fa075ffcab 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveUnexistingManifestsAction.java
@@ -18,37 +18,15 @@
package org.apache.paimon.flink.action;
-import org.apache.paimon.Snapshot;
import org.apache.paimon.catalog.Identifier;
-import org.apache.paimon.fs.FileIO;
-import org.apache.paimon.fs.Path;
-import org.apache.paimon.manifest.ManifestEntry;
-import org.apache.paimon.manifest.ManifestFileMeta;
-import org.apache.paimon.manifest.ManifestList;
-import org.apache.paimon.operation.FileStoreCommitImpl;
-import org.apache.paimon.operation.ManifestsReader;
+import org.apache.paimon.operation.RemoveUnexistingManifests;
import org.apache.paimon.table.FileStoreTable;
-import org.apache.paimon.table.source.ScanMode;
-import org.apache.paimon.utils.FileStorePathFactory;
-import org.apache.paimon.utils.Pair;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
import java.util.Map;
-import java.util.UUID;
-
-import static org.apache.paimon.manifest.ManifestEntry.recordCount;
/** Action to remove the un-existing manifest file. */
public class RemoveUnexistingManifestsAction extends ActionBase implements
LocalAction {
- private static final Logger LOG =
- LoggerFactory.getLogger(RemoveUnexistingManifestsAction.class);
-
private final String databaseName;
private final String tableName;
@@ -63,54 +41,6 @@ public class RemoveUnexistingManifestsAction extends
ActionBase implements Local
public void executeLocally() throws Exception {
Identifier identifier = new Identifier(databaseName, tableName);
FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
- FileIO fileIO = table.fileIO();
- Snapshot latest = table.snapshotManager().latestSnapshot();
- if (latest == null) {
- return;
- }
-
- ManifestsReader manifestsReader =
table.store().newScan().manifestsReader();
- ManifestsReader.Result manifestsResult = manifestsReader.read(latest,
ScanMode.ALL);
- List<ManifestFileMeta> manifests = manifestsResult.allManifests;
- List<ManifestFileMeta> existingManifestFiles = new ArrayList<>();
- List<ManifestEntry> baseManifestEntries = new ArrayList<>();
-
- FileStorePathFactory pathFactory = table.store().pathFactory();
- boolean brokenManifestFile = false;
- for (ManifestFileMeta meta : manifests) {
- try {
- Path path = pathFactory.toManifestFilePath(meta.fileName());
- if (!fileIO.exists(path)) {
- brokenManifestFile = true;
- LOG.warn("Drop manifest file: " + meta.fileName());
- } else {
-
baseManifestEntries.addAll(table.store().newScan().readManifest(meta));
- existingManifestFiles.add(meta);
- }
- } catch (Exception e) {
- throw new RuntimeException("Exception happens", e);
- }
- }
-
- if (!brokenManifestFile) {
- return;
- }
-
- ManifestList manifestList =
table.store().manifestListFactory().create();
- long totalRecordCount = recordCount(baseManifestEntries);
- Pair<String, Long> baseManifestList =
manifestList.write(existingManifestFiles);
- Pair<String, Long> deltaManifestList =
manifestList.write(Collections.emptyList());
-
- try (FileStoreCommitImpl fileStoreCommit =
- (FileStoreCommitImpl)
- table.store().newCommit("Repair-table-" +
UUID.randomUUID(), table)) {
- boolean result =
- fileStoreCommit.replaceManifestList(
- latest, totalRecordCount, baseManifestList,
deltaManifestList);
- if (!result) {
- throw new RuntimeException(
- "Failed, snapshot conflict, maybe multiple jobs is
running to commit snapshots.");
- }
- }
+ new RemoveUnexistingManifests(table).execute();
}
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
index 672099d293..f534773959 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
@@ -56,6 +56,7 @@ import
org.apache.paimon.spark.procedure.ReassignRowIdProcedure;
import org.apache.paimon.spark.procedure.RemoveOrphanBlobsProcedure;
import org.apache.paimon.spark.procedure.RemoveOrphanFilesProcedure;
import org.apache.paimon.spark.procedure.RemoveUnexistingFilesProcedure;
+import org.apache.paimon.spark.procedure.RemoveUnexistingManifestsProcedure;
import org.apache.paimon.spark.procedure.RenameBranchProcedure;
import org.apache.paimon.spark.procedure.RenameTagProcedure;
import org.apache.paimon.spark.procedure.RepairEarliestSnapshotProcedure;
@@ -123,6 +124,8 @@ public class SparkProcedures {
procedureBuilders.put("remove_orphan_files",
RemoveOrphanFilesProcedure::builder);
procedureBuilders.put("remove_orphan_blobs",
RemoveOrphanBlobsProcedure::builder);
procedureBuilders.put("remove_unexisting_files",
RemoveUnexistingFilesProcedure::builder);
+ procedureBuilders.put(
+ "remove_unexisting_manifests",
RemoveUnexistingManifestsProcedure::builder);
procedureBuilders.put("expire_snapshots",
ExpireSnapshotsProcedure::builder);
procedureBuilders.put("expire_partitions",
ExpirePartitionsProcedure::builder);
procedureBuilders.put("repair", RepairProcedure::builder);
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RemoveUnexistingManifestsProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RemoveUnexistingManifestsProcedure.java
new file mode 100644
index 0000000000..92ecd99c3d
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RemoveUnexistingManifestsProcedure.java
@@ -0,0 +1,102 @@
+/*
+ * 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.spark.procedure;
+
+import org.apache.paimon.operation.RemoveUnexistingManifests;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.Preconditions;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
+
+/**
+ * Procedure to remove unexisting manifest files from the manifest list. See
{@link
+ * RemoveUnexistingManifests} for detailed use cases.
+ *
+ * <pre><code>
+ * -- remove unexisting manifest files in table `mydb.myt`
+ * CALL sys.remove_unexisting_manifests(table => 'mydb.myt')
+ *
+ * -- remove unexisting manifest files in a branch
+ * CALL sys.remove_unexisting_manifests(table => 'mydb.`myt$branch_rt`')
+ * </code></pre>
+ *
+ * <p>Note that the user is on their own risk using this procedure, which may
cause data loss when
+ * used outside of the documented repair cases.
+ */
+public class RemoveUnexistingManifestsProcedure extends BaseProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {ProcedureParameter.required("table",
DataTypes.StringType)};
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField("result", DataTypes.StringType, false,
Metadata.empty())
+ });
+
+ private RemoveUnexistingManifestsProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ Identifier tableIdent = toIdentifier(args.getString(0),
PARAMETERS[0].name());
+ return modifyPaimonTable(
+ tableIdent,
+ table -> {
+ Preconditions.checkArgument(
+ table instanceof FileStoreTable,
+ "%s is not a file store table",
+ tableIdent);
+ new RemoveUnexistingManifests((FileStoreTable)
table).execute();
+ return new InternalRow[]
{newInternalRow(UTF8String.fromString("Success"))};
+ });
+ }
+
+ public static ProcedureBuilder builder() {
+ return new BaseProcedure.Builder<RemoveUnexistingManifestsProcedure>()
{
+ @Override
+ public RemoveUnexistingManifestsProcedure doBuild() {
+ return new RemoveUnexistingManifestsProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "RemoveUnexistingManifestsProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RemoveUnexistingManifestsProcedureTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RemoveUnexistingManifestsProcedureTest.scala
new file mode 100644
index 0000000000..055dbbad85
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RemoveUnexistingManifestsProcedureTest.scala
@@ -0,0 +1,159 @@
+/*
+ * 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.spark.procedure
+
+import org.apache.paimon.manifest.ManifestFileMeta
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.table.FileStoreTable
+import org.apache.paimon.table.source.ScanMode
+
+import org.apache.spark.sql.Row
+
+import scala.collection.JavaConverters._
+
+class RemoveUnexistingManifestsProcedureTest extends PaimonSparkTestBase {
+
+ private val tableProperties =
+ """
+ |TBLPROPERTIES (
+ | 'primary-key' = 'k',
+ | 'bucket' = '1',
+ | 'write-only' = 'true',
+ | 'manifest.target-file-size' = '10 B')
+ |""".stripMargin
+
+ test("Paimon Procedure: remove unexisting manifests") {
+ spark.sql(s"""
+ |CREATE TABLE T (k BIGINT, v STRING)
+ |$tableProperties
+ |""".stripMargin)
+
+ spark.sql("INSERT INTO T VALUES (1, 'Hi')")
+ spark.sql("INSERT INTO T VALUES (2, 'Hello')")
+ spark.sql("INSERT INTO T VALUES (3, 'Paimon')")
+
+ val originals =
+ Set(Row(1L, "Hi"), Row(2L, "Hello"), Row(3L, "Paimon"))
+ checkAnswer(spark.sql("SELECT * FROM T ORDER BY k"), originals.toList)
+
+ val (table, manifests) = loadManifests("T")
+ assert(manifests.size() >= 2)
+ val dropped = manifests.get(1)
+ val droppedRows = rowCount(table, dropped)
+ assert(droppedRows > 0 && droppedRows < originals.size)
+
+ val beforeId = table.snapshotManager().latestSnapshot().id()
+
assert(table.fileIO.delete(table.store.pathFactory.toManifestFilePath(dropped.fileName),
false))
+
+ checkAnswer(
+ spark.sql("CALL sys.remove_unexisting_manifests(table => 'test.T')"),
+ Row("Success") :: Nil)
+
+ val remaining = spark.sql("SELECT * FROM T ORDER BY k").collect()
+ assert(remaining.length == originals.size - droppedRows)
+ assert(remaining.forall(originals.contains))
+
+ val repaired = loadTable("T")
+ val latest = repaired.snapshotManager().latestSnapshot()
+ assert(latest.id() == beforeId + 1)
+ assert(latest.totalRecordCount() == remaining.length.toLong)
+ assert(!manifestNames(repaired).contains(dropped.fileName))
+ }
+
+ test("Paimon Procedure: remove unexisting manifests is a no-op when files
exist") {
+ spark.sql("CREATE TABLE T (k INT, v STRING)")
+ spark.sql("INSERT INTO T VALUES (1, 'a')")
+ val beforeId = loadTable("T").snapshotManager().latestSnapshot().id()
+
+ checkAnswer(
+ spark.sql("CALL sys.remove_unexisting_manifests(table => 'test.T')"),
+ Row("Success") :: Nil)
+ checkAnswer(spark.sql("SELECT * FROM T"), Row(1, "a") :: Nil)
+ assert(loadTable("T").snapshotManager().latestSnapshot().id() == beforeId)
+ }
+
+ test("Paimon Procedure: remove unexisting manifests on a branch") {
+ spark.sql(s"""
+ |CREATE TABLE T (k BIGINT, v STRING)
+ |$tableProperties
+ |""".stripMargin)
+ spark.sql("INSERT INTO T VALUES (1, 'Hi')")
+ spark.sql("INSERT INTO T VALUES (2, 'Hello')")
+ spark.sql("INSERT INTO T VALUES (3, 'Paimon')")
+ spark.sql("CALL sys.create_tag(table => 'test.T', tag => 'base')")
+ spark.sql("CALL sys.create_branch(table => 'test.T', branch => 'rt', tag
=> 'base')")
+ spark.sql("INSERT INTO `T$branch_rt` VALUES (4, 'Hi 4')")
+ spark.sql("INSERT INTO `T$branch_rt` VALUES (5, 'Hello 5')")
+ spark.sql("INSERT INTO `T$branch_rt` VALUES (6, 'Paimon 6')")
+
+ val branchRowsBefore = spark.sql("SELECT * FROM `T$branch_rt`").collect()
+ val mainNames = manifestNames(loadTable("T")).toSet
+ val (branch, branchManifests) = loadManifests("T$branch_rt")
+ val dropped = branchManifests.asScala.find(meta =>
!mainNames.contains(meta.fileName()))
+ assert(dropped.isDefined)
+ val droppedMeta = dropped.get
+ val droppedRows = rowCount(branch, droppedMeta)
+ assert(droppedRows > 0)
+
+ assert(
+ branch.fileIO
+
.delete(branch.store.pathFactory.toManifestFilePath(droppedMeta.fileName),
false))
+
+ checkAnswer(
+ spark.sql("CALL sys.remove_unexisting_manifests(table =>
'test.`T$branch_rt`')"),
+ Row("Success") :: Nil)
+
+ val branchRows = spark.sql("SELECT * FROM `T$branch_rt` ORDER BY
k").collect()
+ assert(branchRows.length == branchRowsBefore.length - droppedRows)
+ val repaired = loadTable("T$branch_rt")
+ assert(
+ repaired.snapshotManager().latestSnapshot().totalRecordCount() ==
branchRows.length.toLong)
+ assert(!manifestNames(repaired).contains(droppedMeta.fileName))
+
+ checkAnswer(
+ spark.sql("SELECT * FROM T ORDER BY k"),
+ Row(1L, "Hi") :: Row(2L, "Hello") :: Row(3L, "Paimon") :: Nil)
+ }
+
+ private def loadManifests(
+ tableName: String): (FileStoreTable, java.util.List[ManifestFileMeta]) =
{
+ val table = loadTable(tableName)
+ val manifests = table.store
+ .newScan()
+ .manifestsReader()
+ .read(table.snapshotManager.latestSnapshot, ScanMode.ALL)
+ .allManifests
+ (table, manifests)
+ }
+
+ private def manifestNames(table: FileStoreTable): Seq[String] = {
+ table.store
+ .newScan()
+ .manifestsReader()
+ .read(table.snapshotManager.latestSnapshot, ScanMode.ALL)
+ .allManifests
+ .asScala
+ .map(_.fileName())
+ .toSeq
+ }
+
+ private def rowCount(table: FileStoreTable, manifest: ManifestFileMeta):
Long = {
+
table.store.newScan().readManifest(manifest).asScala.map(_.file().rowCount()).sum
+ }
+}