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 336d730943 [core][python] Add extra files to manifest metadata (#9746)
336d730943 is described below
commit 336d73094360eb34010adcc0544f12881b3e2231
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Sep 11 23:07:21 2026 +0800
[core][python] Add extra files to manifest metadata (#9746)
---
docs/docs/concepts/spec/manifest.md | 4 +
.../apache/paimon/manifest/ManifestFileMeta.java | 55 +++++++++-
.../manifest/ManifestFileMetaSerializer.java | 9 +-
.../apache/paimon/operation/FileDeletionBase.java | 3 +
.../apache/paimon/operation/OrphanFilesClean.java | 5 +
.../manifest/ManifestFileMetaSerializerTest.java | 44 ++++++++
.../apache/paimon/manifest/ManifestListTest.java | 30 +++++-
.../paimon/operation/ExpireSnapshotsTest.java | 114 ++++++++++++++++++++-
.../operation/LocalOrphanFilesCleanTest.java | 55 ++++++++++
.../pypaimon/manifest/manifest_list_manager.py | 2 +
.../pypaimon/manifest/schema/manifest_file_meta.py | 4 +-
.../tests/manifest/manifest_manager_test.py | 40 ++++++++
.../tests/manifest/manifest_schema_test.py | 10 +-
13 files changed, 364 insertions(+), 11 deletions(-)
diff --git a/docs/docs/concepts/spec/manifest.md
b/docs/docs/concepts/spec/manifest.md
index ac216f753e..6ad271bb5a 100644
--- a/docs/docs/concepts/spec/manifest.md
+++ b/docs/docs/concepts/spec/manifest.md
@@ -58,6 +58,10 @@ skip manifests before opening them.
| `_MIN_BUCKET`, `_MAX_BUCKET` | INT, nullable | Bucket bounds in the
manifest. |
| `_MIN_LEVEL`, `_MAX_LEVEL` | INT, nullable | Data-file level bounds in the
manifest. |
| `_MIN_ROW_ID`, `_MAX_ROW_ID` | BIGINT, nullable | Row-ID bounds when
available. |
+| `_EXTRA_FILES` | ARRAY of STRING, nullable | Names of additional files in
the manifest directory; defaults to null. |
+
+Each extra file belongs exclusively to one manifest. It is retained and
cleaned up together with
+that manifest during snapshot, tag, or changelog deletion.
## Manifest
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
index 4a66a3fb0d..2123a419a5 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
@@ -20,6 +20,7 @@ package org.apache.paimon.manifest;
import org.apache.paimon.annotation.Public;
import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.types.ArrayType;
import org.apache.paimon.types.BigIntType;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.IntType;
@@ -57,7 +58,12 @@ public class ManifestFileMeta {
new DataField(8, "_MIN_LEVEL", new IntType(true)),
new DataField(9, "_MAX_LEVEL", new IntType(true)),
new DataField(10, "_MIN_ROW_ID", new
BigIntType(true)),
- new DataField(11, "_MAX_ROW_ID", new
BigIntType(true))));
+ new DataField(11, "_MAX_ROW_ID", new
BigIntType(true)),
+ new DataField(
+ 12,
+ "_EXTRA_FILES",
+ new ArrayType(
+ true, new VarCharType(false,
Integer.MAX_VALUE)))));
private final String fileName;
private final long fileSize;
@@ -71,6 +77,7 @@ public class ManifestFileMeta {
private final @Nullable Integer maxLevel;
private final @Nullable Long minRowId;
private final @Nullable Long maxRowId;
+ private final @Nullable List<String> extraFiles;
public ManifestFileMeta(
String fileName,
@@ -85,6 +92,36 @@ public class ManifestFileMeta {
@Nullable Integer maxLevel,
@Nullable Long minRowId,
@Nullable Long maxRowId) {
+ this(
+ fileName,
+ fileSize,
+ numAddedFiles,
+ numDeletedFiles,
+ partitionStats,
+ schemaId,
+ minBucket,
+ maxBucket,
+ minLevel,
+ maxLevel,
+ minRowId,
+ maxRowId,
+ null);
+ }
+
+ public ManifestFileMeta(
+ String fileName,
+ long fileSize,
+ long numAddedFiles,
+ long numDeletedFiles,
+ SimpleStats partitionStats,
+ long schemaId,
+ @Nullable Integer minBucket,
+ @Nullable Integer maxBucket,
+ @Nullable Integer minLevel,
+ @Nullable Integer maxLevel,
+ @Nullable Long minRowId,
+ @Nullable Long maxRowId,
+ @Nullable List<String> extraFiles) {
this.fileName = fileName;
this.fileSize = fileSize;
this.numAddedFiles = numAddedFiles;
@@ -97,6 +134,7 @@ public class ManifestFileMeta {
this.maxLevel = maxLevel;
this.minRowId = minRowId;
this.maxRowId = maxRowId;
+ this.extraFiles = extraFiles;
}
public String fileName() {
@@ -147,6 +185,10 @@ public class ManifestFileMeta {
return maxRowId;
}
+ public @Nullable List<String> extraFiles() {
+ return extraFiles;
+ }
+
@Override
public boolean equals(Object o) {
if (!(o instanceof ManifestFileMeta)) {
@@ -164,7 +206,8 @@ public class ManifestFileMeta {
&& Objects.equals(minLevel, that.minLevel)
&& Objects.equals(maxLevel, that.maxLevel)
&& Objects.equals(minRowId, that.minRowId)
- && Objects.equals(maxRowId, that.maxRowId);
+ && Objects.equals(maxRowId, that.maxRowId)
+ && Objects.equals(extraFiles, that.extraFiles);
}
@Override
@@ -181,13 +224,14 @@ public class ManifestFileMeta {
minLevel,
maxLevel,
minRowId,
- maxRowId);
+ maxRowId,
+ extraFiles);
}
@Override
public String toString() {
return String.format(
- "{%s, %d, %d, %d, %s, %d, %s, %s, %s, %s, %s, %s}",
+ "{%s, %d, %d, %d, %s, %d, %s, %s, %s, %s, %s, %s, %s}",
fileName,
fileSize,
numAddedFiles,
@@ -199,7 +243,8 @@ public class ManifestFileMeta {
minLevel,
maxLevel,
minRowId,
- maxRowId);
+ maxRowId,
+ extraFiles);
}
// ----------------------- Serialization -----------------------------
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
index 4c07493242..4c2ab90c96 100644
---
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
@@ -25,6 +25,9 @@ import org.apache.paimon.stats.SimpleStats;
import org.apache.paimon.utils.ObjectSerializer;
import org.apache.paimon.utils.OffsetRow;
+import static org.apache.paimon.utils.InternalRowUtils.fromStringArrayData;
+import static org.apache.paimon.utils.InternalRowUtils.toStringArrayData;
+
/** Serializer for {@link ManifestFileMeta}. */
public class ManifestFileMetaSerializer extends
ObjectSerializer<ManifestFileMeta> {
@@ -56,7 +59,8 @@ public class ManifestFileMetaSerializer extends
ObjectSerializer<ManifestFileMet
meta.minLevel(),
meta.maxLevel(),
meta.minRowId(),
- meta.maxRowId());
+ meta.maxRowId(),
+ toStringArrayData(meta.extraFiles()));
}
@Override
@@ -90,6 +94,7 @@ public class ManifestFileMetaSerializer extends
ObjectSerializer<ManifestFileMet
row.isNullAt(8) ? null : row.getInt(8),
row.isNullAt(9) ? null : row.getInt(9),
row.isNullAt(10) ? null : row.getLong(10),
- row.isNullAt(11) ? null : row.getLong(11));
+ row.isNullAt(11) ? null : row.getLong(11),
+ row.isNullAt(12) ? null :
fromStringArrayData(row.getArray(12)));
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
b/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
index a0545c87e4..6494411a8c 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
@@ -326,6 +326,9 @@ public abstract class FileDeletionBase<T extends Snapshot> {
String fileName = manifest.fileName();
if (skippingSet.add(fileName)) {
manifests.add(fileName);
+ if (manifest.extraFiles() != null) {
+ manifests.addAll(manifest.extraFiles());
+ }
}
}
if (skippingSet.add(manifestName)) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
b/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
index 4245460225..04b4ae63ff 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
@@ -309,6 +309,11 @@ public abstract class OrphanFilesClean implements
Serializable {
// collect manifests
for (ManifestFileMeta manifest : manifestFileMetas) {
usedFileWithFlagConsumer.accept(Pair.of(manifest.fileName(),
true));
+ if (manifest.extraFiles() != null) {
+ for (String extraFile : manifest.extraFiles()) {
+ usedFileWithFlagConsumer.accept(Pair.of(extraFile, false));
+ }
+ }
}
// index files
diff --git
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
index 57b5a08ed0..2f4e32cd27 100644
---
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
@@ -23,7 +23,10 @@ import org.apache.paimon.utils.ObjectSerializerTestBase;
import org.junit.jupiter.api.Test;
+import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -40,6 +43,47 @@ public class ManifestFileMetaSerializerTest extends
ObjectSerializerTestBase<Man
assertThat(new
ManifestFileMetaSerializer().toRow(object()).getInt(0)).isEqualTo(2);
}
+ @Test
+ void testExtraFiles() throws IOException {
+ ManifestFileMeta original = object();
+ assertThat(original.extraFiles()).isNull();
+
+ ManifestFileMetaSerializer serializer = new
ManifestFileMetaSerializer();
+ for (List<String> extraFiles :
+ Arrays.asList(
+ null,
+ Collections.<String>emptyList(),
+ Arrays.asList("extra-1", "extra-2"))) {
+ ManifestFileMeta meta =
+ new ManifestFileMeta(
+ original.fileName(),
+ original.fileSize(),
+ original.numAddedFiles(),
+ original.numDeletedFiles(),
+ original.partitionStats(),
+ original.schemaId(),
+ original.minBucket(),
+ original.maxBucket(),
+ original.minLevel(),
+ original.maxLevel(),
+ original.minRowId(),
+ original.maxRowId(),
+ extraFiles);
+
+ ManifestFileMeta fromRow =
serializer.fromRow(serializer.toRow(meta));
+ ManifestFileMeta fromBytes =
serializer.deserializeFromBytes(meta.toBytes());
+ assertThat(fromRow).isEqualTo(meta);
+ assertThat(fromBytes).isEqualTo(meta).hasSameHashCodeAs(meta);
+ assertThat(fromRow.extraFiles()).isEqualTo(extraFiles);
+ assertThat(fromBytes.extraFiles()).isEqualTo(extraFiles);
+ if (extraFiles == null) {
+
assertThat(meta).isEqualTo(original).hasSameHashCodeAs(original);
+ } else {
+ assertThat(meta).isNotEqualTo(original);
+ }
+ }
+ }
+
@Override
protected ObjectSerializer<ManifestFileMeta> serializer() {
return new ManifestFileMetaSerializer();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
index 8442be28c6..4891a760b7 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
@@ -35,6 +35,8 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.UUID;
@@ -57,6 +59,9 @@ public class ManifestListTest {
String manifestListName = manifestList.write(metas).getKey();
List<ManifestFileMeta> actualMetas =
manifestList.read(manifestListName);
assertThat(actualMetas).isEqualTo(metas);
+ for (int i = 0; i < metas.size(); i++) {
+
assertThat(actualMetas.get(i).extraFiles()).isEqualTo(metas.get(i).extraFiles());
+ }
}
@RepeatedTest(10)
@@ -96,6 +101,7 @@ public class ManifestListTest {
ManifestList manifestList = createManifestList(tempDir.toString());
List<ManifestFileMeta> actualMetas =
manifestList.read(manifestListName);
assertThat(actualMetas).isEqualTo(getLegacyMetaPaimon10(metas));
+ assertThat(actualMetas).allSatisfy(meta ->
assertThat(meta.extraFiles()).isNull());
}
@Test
@@ -107,6 +113,7 @@ public class ManifestListTest {
ManifestList legacyManifestList = createLegacyManifestListPaimon10();
List<ManifestFileMeta> actualMetas =
legacyManifestList.read(manifestListName);
assertThat(actualMetas).isEqualTo(getLegacyMetaPaimon10(metas));
+ assertThat(actualMetas).allSatisfy(meta ->
assertThat(meta.extraFiles()).isNull());
}
private ManifestList createLegacyManifestListPaimon10() {
@@ -156,7 +163,28 @@ public class ManifestListTest {
for (int j = random.nextInt(10) + 1; j > 0; j--) {
entries.add(gen.next());
}
- metas.add(gen.createManifestFileMeta(entries));
+ ManifestFileMeta meta = gen.createManifestFileMeta(entries);
+ List<String> extraFiles =
+ i % 3 == 0
+ ? null
+ : i % 3 == 1
+ ? Collections.emptyList()
+ : Arrays.asList("extra-" + i + "-1",
"extra-" + i + "-2");
+ metas.add(
+ new ManifestFileMeta(
+ meta.fileName(),
+ meta.fileSize(),
+ meta.numAddedFiles(),
+ meta.numDeletedFiles(),
+ meta.partitionStats(),
+ meta.schemaId(),
+ meta.minBucket(),
+ meta.maxBucket(),
+ meta.minLevel(),
+ meta.maxLevel(),
+ meta.minRowId(),
+ meta.maxRowId(),
+ extraFiles));
}
return metas;
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
index c5a21e57c9..8dfcb1c21a 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
@@ -60,6 +60,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.nio.file.Files;
@@ -404,10 +406,15 @@ public class ExpireSnapshotsTest {
private Snapshot snapshotWithManifestLists(
String deltaManifestList, String changelogManifestList) {
+ return snapshotWithManifestLists(null, deltaManifestList,
changelogManifestList);
+ }
+
+ private Snapshot snapshotWithManifestLists(
+ String baseManifestList, String deltaManifestList, String
changelogManifestList) {
return new Snapshot(
0,
0L,
- null,
+ baseManifestList,
null,
deltaManifestList,
null,
@@ -429,6 +436,111 @@ public class ExpireSnapshotsTest {
null);
}
+ @Test
+ public void testCleanUnusedManifestExtraFiles() throws Exception {
+ ManifestFileMeta base = manifestWithExtraFiles("base", null);
+ ManifestFileMeta empty = manifestWithExtraFiles("empty",
Collections.emptyList());
+ ManifestFileMeta delta =
+ manifestWithExtraFiles("delta", Arrays.asList("delta-extra-1",
"delta-extra-2"));
+ ManifestFileMeta changelog =
+ manifestWithExtraFiles("changelog",
Collections.singletonList("changelog-extra"));
+ Snapshot snapshot =
+ snapshotWithManifestLists(
+ writeManifestList(base, empty),
+ writeManifestList(delta),
+ writeManifestList(changelog));
+
+ store.newSnapshotDeletion().cleanUnusedManifests(snapshot, new
HashSet<>());
+
+ for (String name :
+ Arrays.asList(
+ "base",
+ "empty",
+ "delta",
+ "changelog",
+ "delta-extra-1",
+ "delta-extra-2",
+ "changelog-extra",
+ snapshot.baseManifestList(),
+ snapshot.deltaManifestList(),
+ snapshot.changelogManifestList())) {
+
assertThat(fileIO.exists(store.pathFactory().toManifestFilePath(name)))
+ .as(name)
+ .isFalse();
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"snapshot", "tag", "changelog"})
+ public void testCleanManifestExtraFilesFollowsManifestRetention(String
cleaner)
+ throws Exception {
+ ManifestFileMeta expired =
+ manifestWithExtraFiles("expired",
Collections.singletonList("expired-extra"));
+ ManifestFileMeta retained =
+ manifestWithExtraFiles("retained",
Collections.singletonList("retained-extra"));
+ Snapshot expiredSnapshot =
+ snapshotWithManifestLists(
+ writeManifestList(), writeManifestList(expired,
retained), null);
+ Snapshot retainedSnapshot =
+ snapshotWithManifestLists(writeManifestList(),
writeManifestList(retained), null);
+
+ SnapshotDeletion snapshotDeletion = store.newSnapshotDeletion();
+ List<Snapshot> skippingSnapshots =
Collections.singletonList(retainedSnapshot);
+ if ("changelog".equals(cleaner)) {
+ ChangelogDeletion deletion = store.newChangelogDeletion();
+ deletion.cleanUnusedManifestList(
+ expiredSnapshot.deltaManifestList(),
+ deletion.manifestSkippingSet(skippingSnapshots));
+ } else {
+ FileDeletionBase<Snapshot> deletion =
+ "tag".equals(cleaner) ? store.newTagDeletion() :
snapshotDeletion;
+ deletion.cleanUnusedManifests(
+ expiredSnapshot,
deletion.manifestSkippingSet(skippingSnapshots));
+ }
+
+
assertThat(fileIO.exists(store.pathFactory().toManifestFilePath("expired"))).isFalse();
+
assertThat(fileIO.exists(store.pathFactory().toManifestFilePath("expired-extra")))
+ .isFalse();
+ for (String name : Arrays.asList("retained", "retained-extra")) {
+
assertThat(fileIO.exists(store.pathFactory().toManifestFilePath(name)))
+ .as(name)
+ .isTrue();
+ }
+
+ snapshotDeletion.cleanUnusedManifests(retainedSnapshot, new
HashSet<>());
+
assertThat(fileIO.exists(store.pathFactory().toManifestFilePath("retained"))).isFalse();
+
assertThat(fileIO.exists(store.pathFactory().toManifestFilePath("retained-extra")))
+ .isFalse();
+ }
+
+ private ManifestFileMeta manifestWithExtraFiles(String fileName,
List<String> extraFiles)
+ throws IOException {
+ fileIO.writeFile(store.pathFactory().toManifestFilePath(fileName),
"manifest", true);
+ if (extraFiles != null) {
+ for (String extraFile : extraFiles) {
+
fileIO.writeFile(store.pathFactory().toManifestFilePath(extraFile), "extra",
true);
+ }
+ }
+ return new ManifestFileMeta(
+ fileName,
+ 0L,
+ 0L,
+ 0L,
+ SimpleStats.EMPTY_STATS,
+ 0L,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ extraFiles);
+ }
+
+ private String writeManifestList(ManifestFileMeta... manifests) {
+ return
store.manifestListFactory().create().write(Arrays.asList(manifests)).getKey();
+ }
+
@Test
public void testNoSnapshot() throws IOException {
ExpireSnapshots expire = store.newExpire(1, 3, Long.MAX_VALUE);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/LocalOrphanFilesCleanTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/LocalOrphanFilesCleanTest.java
index bfe3f6d76c..36499bb4a6 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/LocalOrphanFilesCleanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/LocalOrphanFilesCleanTest.java
@@ -31,6 +31,7 @@ import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.manifest.ManifestFileMeta;
import org.apache.paimon.manifest.ManifestList;
import org.apache.paimon.mergetree.compact.ConcatRecordReader;
import org.apache.paimon.options.Options;
@@ -52,10 +53,14 @@ import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowKind;
import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.JsonSerdeUtil;
+import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.Preconditions;
import org.apache.paimon.utils.SnapshotManager;
import org.apache.paimon.utils.StringUtils;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.node.ObjectNode;
+
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -541,6 +546,56 @@ public class LocalOrphanFilesCleanTest {
validate(deleted, snapshotData, changelogData);
}
+ @Test
+ public void testPreservesManifestExtraFiles() throws Exception {
+ commit(generateData());
+ SnapshotManager snapshotManager = table.snapshotManager();
+ Snapshot snapshot = snapshotManager.latestSnapshot();
+ ManifestList manifestList =
table.store().manifestListFactory().create();
+ List<ManifestFileMeta> manifests =
manifestList.read(snapshot.deltaManifestList());
+ ManifestFileMeta meta = manifests.get(0);
+ String extraFile = "manifest-extra";
+ manifests.set(
+ 0,
+ new ManifestFileMeta(
+ meta.fileName(),
+ meta.fileSize(),
+ meta.numAddedFiles(),
+ meta.numDeletedFiles(),
+ meta.partitionStats(),
+ meta.schemaId(),
+ meta.minBucket(),
+ meta.maxBucket(),
+ meta.minLevel(),
+ meta.maxLevel(),
+ meta.minRowId(),
+ meta.maxRowId(),
+ Collections.singletonList(extraFile)));
+ Pair<String, Long> newManifestList = manifestList.write(manifests);
+ ObjectNode node =
+ (ObjectNode)
JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.readTree(snapshot.toJson());
+ node.put("deltaManifestList", newManifestList.getKey());
+ node.put("deltaManifestListSize", newManifestList.getValue());
+ fileIO.overwriteFileUtf8(snapshotManager.snapshotPath(snapshot.id()),
node.toString());
+ snapshotManager.invalidateCache();
+
+ Path extraPath = new Path(manifestDir, extraFile);
+ Path orphanPath = new Path(manifestDir, "orphan-extra");
+ fileIO.writeFile(extraPath, "extra file, not an Avro manifest", true);
+ fileIO.writeFile(orphanPath, "orphan", true);
+
+ LocalOrphanFilesClean cleaner =
+ new LocalOrphanFilesClean(
+ table, System.currentTimeMillis() +
TimeUnit.SECONDS.toMillis(2));
+ List<Path> deleted = cleaner.clean().getDeletedFilesPath();
+ assertThat(deleted)
+ .extracting(Path::getName)
+ .contains(orphanPath.getName())
+ .doesNotContain(extraFile);
+ assertThat(fileIO.exists(extraPath)).isTrue();
+ assertThat(fileIO.exists(orphanPath)).isFalse();
+ }
+
/** Manually make a FileNotFoundException to simulate snapshot expire
while clean. */
@Test
public void testAbnormallyRemoving() throws Exception {
diff --git a/paimon-python/pypaimon/manifest/manifest_list_manager.py
b/paimon-python/pypaimon/manifest/manifest_list_manager.py
index 3a0e606ef5..5b867e1ae2 100644
--- a/paimon-python/pypaimon/manifest/manifest_list_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_list_manager.py
@@ -98,6 +98,7 @@ class ManifestListManager:
schema_id=record['_SCHEMA_ID'],
min_row_id=record.get('_MIN_ROW_ID'),
max_row_id=record.get('_MAX_ROW_ID'),
+ extra_files=record.get('_EXTRA_FILES'),
)
manifest_files.append(manifest_file_meta)
@@ -120,6 +121,7 @@ class ManifestListManager:
"_SCHEMA_ID": meta.schema_id,
"_MIN_ROW_ID": meta.min_row_id,
"_MAX_ROW_ID": meta.max_row_id,
+ "_EXTRA_FILES": meta.extra_files,
}
avro_records.append(avro_record)
diff --git a/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
b/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
index 3c45b71695..2681adacdd 100644
--- a/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
+++ b/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
@@ -17,7 +17,7 @@
from dataclasses import dataclass
-from typing import Optional
+from typing import List, Optional
from pypaimon.manifest.schema.simple_stats import (PARTITION_STATS_SCHEMA,
SimpleStats)
@@ -33,6 +33,7 @@ class ManifestFileMeta:
min_row_id: Optional[int] = None
max_row_id: Optional[int] = None
+ extra_files: Optional[List[str]] = None
MANIFEST_FILE_META_SCHEMA = {
"type": "record",
@@ -47,5 +48,6 @@ MANIFEST_FILE_META_SCHEMA = {
{"name": "_SCHEMA_ID", "type": "long"},
{"name": "_MIN_ROW_ID", "type": ["null", "long"], "default": None},
{"name": "_MAX_ROW_ID", "type": ["null", "long"], "default": None},
+ {"name": "_EXTRA_FILES", "type": ["null", {"type": "array", "items":
"string"}], "default": None},
]
}
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
index ad4754e057..e556c55cf8 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
@@ -22,7 +22,9 @@ import sys
import tempfile
import threading
import unittest
+from io import BytesIO
+import fastavro
import pyarrow as pa
from pypaimon.catalog.filesystem_catalog import FileSystemCatalog
@@ -465,6 +467,44 @@ class ManifestListManagerTest(_ManifestManagerSetup):
)
manager.write(name, [meta])
+ def test_extra_files_round_trip(self):
+ manager = self._make_manager()
+ expected_extra_files = [None, [], ["extra-1", "extra-2"]]
+ metas = []
+ for i, extra_files in enumerate(expected_extra_files):
+ meta = ManifestFileMeta(
+ file_name=f"manifest-{i}.avro", file_size=1024,
+ num_added_files=1, num_deleted_files=0,
+ partition_stats=SimpleStats.empty_stats(), schema_id=0,
+ )
+ self.assertIsNone(meta.extra_files)
+ meta.extra_files = extra_files
+ metas.append(meta)
+
+ name = "manifest-list-extra-files"
+ manager.write(name, metas)
+ actual = manager.read(name)
+ self.assertEqual([meta.file_name for meta in actual],
+ [meta.file_name for meta in metas])
+ self.assertEqual([meta.extra_files for meta in actual],
expected_extra_files)
+
+ with
manager.file_io.new_input_stream(f"{manager.manifest_path}/{name}") as stream:
+ avro_bytes = stream.read()
+ reader = fastavro.reader(BytesIO(avro_bytes))
+ records = list(reader)
+ self.assertEqual([record["_VERSION"] for record in records], [2, 2, 2])
+ self.assertEqual([record["_EXTRA_FILES"] for record in records],
expected_extra_files)
+
+ legacy_schema = reader.writer_schema
+ legacy_schema["fields"] = [
+ field for field in legacy_schema["fields"] if field["name"] !=
"_EXTRA_FILES"
+ ]
+ legacy_records = list(fastavro.reader(BytesIO(avro_bytes),
reader_schema=legacy_schema))
+ self.assertEqual([record["_FILE_NAME"] for record in legacy_records],
+ [meta.file_name for meta in metas])
+ for record in legacy_records:
+ self.assertNotIn("_EXTRA_FILES", record)
+
def _make_snapshot(self, base_manifest_list,
delta_manifest_list="delta-manifest-list"):
from pypaimon.snapshot.snapshot import Snapshot
return Snapshot(
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
b/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
index f483d84ccc..1e7bfa3670 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
@@ -130,7 +130,7 @@ class ManifestSchemaTest(unittest.TestCase):
expected_fields = [
"_VERSION", "_FILE_NAME", "_FILE_SIZE", "_NUM_ADDED_FILES",
"_NUM_DELETED_FILES", "_PARTITION_STATS", "_SCHEMA_ID",
- "_MIN_ROW_ID", "_MAX_ROW_ID",
+ "_MIN_ROW_ID", "_MAX_ROW_ID", "_EXTRA_FILES",
]
for field_name in expected_fields:
@@ -146,6 +146,9 @@ class ManifestSchemaTest(unittest.TestCase):
self.assertEqual(field_map["_SCHEMA_ID"]["type"], "long")
self.assertEqual(field_map["_MIN_ROW_ID"]["type"], ["null", "long"])
self.assertEqual(field_map["_MAX_ROW_ID"]["type"], ["null", "long"])
+ self.assertEqual(field_map["_EXTRA_FILES"]["type"],
+ ["null", {"type": "array", "items": "string"}])
+ self.assertIsNone(field_map["_EXTRA_FILES"]["default"])
self.assertIsNone(
field_map["_MIN_ROW_ID"].get("default"),
"_MIN_ROW_ID should have default None for backward compatibility",
@@ -232,3 +235,8 @@ class ManifestSchemaTest(unittest.TestCase):
self.assertEqual(meta.schema_id, 0)
self.assertIsNone(meta.min_row_id)
self.assertIsNone(meta.max_row_id)
+ self.assertIsNone(meta.extra_files)
+
+ buffer.seek(0)
+ resolved_record = next(fastavro.reader(buffer,
reader_schema=MANIFEST_FILE_META_SCHEMA))
+ self.assertIsNone(resolved_record["_EXTRA_FILES"])