diff --git a/docs/docs/concepts/spec/manifest.md b/docs/docs/concepts/spec/manifest.md
index ada9baf1cee1..29466c1ad683 100644
--- a/docs/docs/concepts/spec/manifest.md
+++ b/docs/docs/concepts/spec/manifest.md
@@ -71,11 +71,14 @@ using independent partition, row-ID and bucket coverage. A sidecar uses the
`.avro.sidecar` reference in the manifest metadata's `_EXTRA_FILES`, without probing a
derived file name. The Avro schemas and `_VERSION` identifiers remain unchanged.
-The utility includes construction, validation, block selection and optional caching. Table
-writers and scans do not yet invoke it automatically. Callers are responsible for publishing
-sidecar references, managing file ownership, applying entry filters and reconciling ADD/DELETE
-entries after block selection. `build` reads the completed physical manifest and returns
-sidecar bytes; it does not write or publish another file.
+The utility includes construction, validation, block selection and optional caching. Java table
+writers generate sidecars when `manifest.sidecar.enabled` is true; when unset, it inherits
+`manifest-sort.enabled`. Both ordinary writes and raw manifest rewrites build the sidecar from
+the completed output manifest and publish its `_EXTRA_FILES` reference only after both files
+close successfully. Failed writes and aborted writers clean up their own manifest/sidecar pairs.
+Scans do not yet invoke sidecar pruning automatically. Callers remain responsible for applying
+entry filters and reconciling ADD/DELETE entries after block selection. The low-level `build`
+method returns sidecar bytes without writing or publishing another file.
Callers decide whether to invoke `build` and `read`; these utilities have no read/write switches.
`build` and `Builder` accept `rowIdEnabled` and `bucketEnabled` arguments for independent
diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html
index 16d3339f41f9..cf48d473c4ec 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1113,6 +1113,12 @@
Boolean |
Whether to skip automatic manifest merging during commit when write-only is true. This also skips automatic manifest sort rewrite. Explicit manifest compaction is not affected. |
+
+ manifest.sidecar.enabled |
+ (none) |
+ Boolean |
+ Whether to enable manifest sidecars with independent partition, row-id and bucket coverage. Defaults to manifest-sort.enabled when unset. |
+
manifest.target-file-size |
8 mb |
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 68215e18e4f0..9bb324b78072 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -522,6 +522,13 @@ public InlineElement getDescription() {
.defaultValue(MemorySize.ofMebiBytes(8))
.withDescription("Suggested file size of a manifest file.");
+ public static final ConfigOption MANIFEST_SIDECAR_ENABLED =
+ key("manifest.sidecar.enabled")
+ .booleanType()
+ .noDefaultValue()
+ .withDescription(
+ "Whether to enable manifest sidecars with independent partition, row-id and bucket coverage. Defaults to manifest-sort.enabled when unset.");
+
public static final ConfigOption MANIFEST_FULL_COMPACTION_FILE_SIZE =
key("manifest.full-compaction-threshold-size")
.memoryType()
@@ -3217,6 +3224,10 @@ public MemorySize manifestTargetSize() {
return options.get(MANIFEST_TARGET_FILE_SIZE);
}
+ public boolean manifestSidecarEnabled() {
+ return options.getOptional(MANIFEST_SIDECAR_ENABLED).orElseGet(this::manifestSortEnabled);
+ }
+
public MemorySize manifestFullCompactionThresholdSize() {
return options.get(MANIFEST_FULL_COMPACTION_FILE_SIZE);
}
diff --git a/paimon-benchmark/paimon-micro-benchmarks/src/test/java/org/apache/paimon/benchmark/ManifestFileSorterBenchmark.java b/paimon-benchmark/paimon-micro-benchmarks/src/test/java/org/apache/paimon/benchmark/ManifestFileSorterBenchmark.java
index 5a5f31d875d0..e805efb69443 100644
--- a/paimon-benchmark/paimon-micro-benchmarks/src/test/java/org/apache/paimon/benchmark/ManifestFileSorterBenchmark.java
+++ b/paimon-benchmark/paimon-micro-benchmarks/src/test/java/org/apache/paimon/benchmark/ManifestFileSorterBenchmark.java
@@ -300,7 +300,8 @@ private ManifestFile createManifestFile() {
"zstd",
pathFactory,
TARGET_MANIFEST_SIZE,
- null)
+ null,
+ new CoreOptions(new Options()))
.create();
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
index 7399e057783c..29c7ab8de037 100644
--- a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
@@ -211,7 +211,8 @@ public ManifestFile.Factory manifestFileFactory() {
options.manifestCompression(),
pathFactory(),
options.manifestTargetSize().getBytes(),
- readManifestCache);
+ readManifestCache,
+ options);
}
@Override
diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java
index 4dabe54f3f2b..92c81bea7c53 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java
@@ -18,6 +18,7 @@
package org.apache.paimon.manifest;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.SimpleColStats;
@@ -43,6 +44,7 @@
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
@@ -68,6 +70,7 @@ public final class ManifestAvroWriter implements AutoCloseable {
private final String compression;
private final PathFactory pathFactory;
private final long targetFileSize;
+ private final CoreOptions options;
private final List results = new ArrayList<>();
private final List completedPaths = new ArrayList<>();
@@ -83,7 +86,8 @@ public final class ManifestAvroWriter implements AutoCloseable {
ObjectSerializer serializer,
String compression,
PathFactory pathFactory,
- long targetFileSize) {
+ long targetFileSize,
+ CoreOptions options) {
this.fileIO = fileIO;
this.schemaManager = schemaManager;
this.partitionType = partitionType;
@@ -92,6 +96,7 @@ public final class ManifestAvroWriter implements AutoCloseable {
this.compression = compression;
this.pathFactory = pathFactory;
this.targetFileSize = targetFileSize;
+ this.options = options;
}
public void write(ManifestEntry entry) throws IOException {
@@ -218,6 +223,9 @@ private void closeCurrentWriter() throws IOException {
currentWriter.close();
ManifestFileMeta result = currentWriter.result();
completedPaths.add(currentWriter.path);
+ if (currentWriter.sidecarCreated) {
+ completedPaths.add(ManifestSidecar.path(currentWriter.path));
+ }
results.add(result);
currentWriter = null;
}
@@ -413,6 +421,7 @@ private final class FileWriter {
private @Nullable RowIdStats rowIdStats = new RowIdStats();
private boolean closed;
private boolean aborted;
+ private boolean sidecarCreated;
private FileWriter(Path path) {
this.path = path;
@@ -488,7 +497,7 @@ private void collectStats(ManifestEntry entry) {
maxLevel = Math.max(maxLevel, entry.level());
if (rowIdStats != null) {
Long firstRowId = entry.file().firstRowId();
- if (firstRowId == null) {
+ if (!validRowIdRange(firstRowId, entry.file().rowCount())) {
rowIdStats = null;
} else {
rowIdStats.collect(firstRowId, entry.file().rowCount());
@@ -515,7 +524,7 @@ private void collectStats(EncodedEntry entry) {
minLevel = Math.min(minLevel, entry.level);
maxLevel = Math.max(maxLevel, entry.level);
if (rowIdStats != null) {
- if (!entry.hasRowId) {
+ if (!entry.hasRowId || !validRowIdRange(entry.firstRowId, entry.rowCount)) {
rowIdStats = null;
} else {
rowIdStats.collect(entry.firstRowId, entry.rowCount);
@@ -697,6 +706,14 @@ private Throwable abortCollecting(@Nullable Throwable primaryFailure, boolean de
ExceptionUtils.firstOrSuppressed(cleanupFailure, primaryFailure);
}
}
+ if (sidecarCreated) {
+ try {
+ fileIO.deleteQuietly(ManifestSidecar.path(path));
+ } catch (Throwable cleanupFailure) {
+ primaryFailure =
+ ExceptionUtils.firstOrSuppressed(cleanupFailure, primaryFailure);
+ }
+ }
return primaryFailure;
}
@@ -711,6 +728,7 @@ private void close() throws IOException {
outputBytes = out.getPos();
out.close();
out = null;
+ writeSidecar();
} catch (IOException | RuntimeException | Error failure) {
abortCollecting(failure, true);
throw failure;
@@ -719,6 +737,26 @@ private void close() throws IOException {
}
}
+ private void writeSidecar() throws IOException {
+ if (!options.manifestSidecarEnabled()) {
+ return;
+ }
+ byte[] bytes =
+ ManifestSidecar.build(
+ fileIO,
+ path,
+ outputBytes,
+ Math.addExact(numAddedFiles, numDeletedFiles),
+ options.dataEvolutionEnabled(),
+ options.bucket() != -1);
+ // Publish result() only after both immutable objects have closed. No rename.
+ try (PositionOutputStream sidecarOut =
+ fileIO.newOutputStream(ManifestSidecar.path(path), false)) {
+ sidecarCreated = true;
+ sidecarOut.write(bytes);
+ }
+ }
+
private ManifestFileMeta result() {
if (!closed || outputBytes == null) {
throw new IllegalStateException(
@@ -740,10 +778,16 @@ private ManifestFileMeta result() {
rowIdStats == null ? null : rowIdStats.minRowId,
rowIdStats == null ? null : rowIdStats.maxRowId,
totalBucketsKnown ? totalBuckets : null,
- null);
+ sidecarCreated
+ ? Collections.singletonList(ManifestSidecar.path(path).getName())
+ : null);
}
}
+ private static boolean validRowIdRange(@Nullable Long first, long count) {
+ return first != null && first >= 0 && count > 0 && count - 1 <= Long.MAX_VALUE - first;
+ }
+
private static class RowIdStats {
private long minRowId = Long.MAX_VALUE;
diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
index 9781be4b90a9..30e0adcf1bf3 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
@@ -18,6 +18,7 @@
package org.apache.paimon.manifest;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.FileFormat;
@@ -59,6 +60,7 @@ public class ManifestFile extends ObjectsFile {
private final RowType partitionType;
private final AvroFileFormat avroFileFormat;
private final long suggestedFileSize;
+ private final CoreOptions options;
private ManifestFile(
FileIO fileIO,
@@ -69,7 +71,8 @@ private ManifestFile(
String compression,
PathFactory pathFactory,
long suggestedFileSize,
- @Nullable SegmentsCache cache) {
+ @Nullable SegmentsCache cache,
+ CoreOptions options) {
super(
fileIO,
serializer,
@@ -85,6 +88,7 @@ private ManifestFile(
this.partitionType = partitionType;
this.avroFileFormat = avroFileFormat;
this.suggestedFileSize = suggestedFileSize;
+ this.options = options;
}
@Override
@@ -301,7 +305,8 @@ public ManifestAvroWriter createAvroWriter() {
serializer,
compression,
pathFactory,
- suggestedFileSize);
+ suggestedFileSize,
+ options);
}
/** Creates an Avro manifest writer for one explicit path. */
@@ -314,7 +319,8 @@ public ManifestAvroWriter createAvroWriter(Path manifestPath) {
serializer,
compression,
singlePathFactory(manifestPath),
- Long.MAX_VALUE);
+ Long.MAX_VALUE,
+ options);
}
private PathFactory singlePathFactory(Path manifestPath) {
@@ -357,6 +363,7 @@ public static class Factory {
private final String compression;
private final FileStorePathFactory pathFactory;
private final long suggestedFileSize;
+ private final CoreOptions options;
@Nullable private final SegmentsCache cache;
public Factory(
@@ -367,7 +374,8 @@ public Factory(
String compression,
FileStorePathFactory pathFactory,
long suggestedFileSize,
- @Nullable SegmentsCache cache) {
+ @Nullable SegmentsCache cache,
+ CoreOptions options) {
this.fileIO = fileIO;
this.schemaManager = schemaManager;
this.partitionType = partitionType;
@@ -376,6 +384,7 @@ public Factory(
this.pathFactory = pathFactory;
this.suggestedFileSize = suggestedFileSize;
this.cache = cache;
+ this.options = options;
}
public boolean isCacheEnabled() {
@@ -392,7 +401,8 @@ public ManifestFile create() {
compression,
pathFactory.manifestFileFactory(),
suggestedFileSize,
- cache);
+ cache,
+ options);
}
}
}
diff --git a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
index 47aa94acd942..e83ca0d4acea 100644
--- a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
@@ -29,6 +29,24 @@
/** Tests for {@link org.apache.paimon.CoreOptions}. */
public class CoreOptionsTest {
+ @Test
+ void testManifestSidecarDefaultsToManifestSort() {
+ assertThat(CoreOptions.MANIFEST_SIDECAR_ENABLED.defaultValue()).isNull();
+ for (Boolean sort : new Boolean[] {null, false, true}) {
+ for (Boolean configured : new Boolean[] {null, false, true}) {
+ Options options = new Options();
+ if (sort != null) {
+ options.set(CoreOptions.MANIFEST_SORT_ENABLED, sort);
+ }
+ if (configured != null) {
+ options.set(CoreOptions.MANIFEST_SIDECAR_ENABLED, configured);
+ }
+ assertThat(new CoreOptions(options).manifestSidecarEnabled())
+ .isEqualTo(configured == null ? Boolean.TRUE.equals(sort) : configured);
+ }
+ }
+ }
+
@Test
public void testDefaultStartupMode() {
Options conf = new Options();
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
index ad3641b121b4..16f33f4cee97 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
@@ -2744,7 +2744,8 @@ public void testManifestSortWithMultiplePartitions() {
false,
null),
Long.MAX_VALUE,
- null)
+ null,
+ new CoreOptions(new Options()))
.create();
List input = new ArrayList<>();
@@ -3204,7 +3205,8 @@ private ManifestFile createManifestFileForPartitionType(RowType partitionType) {
false,
null),
Long.MAX_VALUE,
- null)
+ null,
+ new CoreOptions(new Options()))
.create();
}
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTestBase.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTestBase.java
index 5dd1e4787b8f..a4516015c29b 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTestBase.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTestBase.java
@@ -166,7 +166,8 @@ protected ManifestFile createManifestFile(String pathStr, FileIO fileIO) {
false,
null),
Long.MAX_VALUE,
- null)
+ null,
+ new CoreOptions(new Options()))
.create();
}
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
index 50097b041aa0..0362871ef826 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
@@ -1442,7 +1442,8 @@ private ManifestFile createManifestFile(
"zstd",
pathFactory,
suggestedFileSize,
- cache)
+ cache,
+ new CoreOptions(new Options()))
.create();
}
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestIndexTestUtils.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestIndexTestUtils.java
index 32a94c9a5bf7..e9be1afc3f59 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestIndexTestUtils.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestIndexTestUtils.java
@@ -35,6 +35,7 @@
/** Synthetic index references for manifest serialization and lifecycle tests. */
public final class ManifestIndexTestUtils {
+
private ManifestIndexTestUtils() {}
public static ManifestFileMeta withIndexFileName(ManifestFileMeta meta, String indexFileName) {
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarTest.java
index b4a7b3e29bf3..1110c7015861 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarTest.java
@@ -18,6 +18,7 @@
package org.apache.paimon.manifest;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.BinaryRowWriter;
import org.apache.paimon.data.BinaryString;
@@ -276,7 +277,8 @@ private ManifestAvroWriter writer(FileIO io, Path path) {
new ManifestEntrySerializer(),
"zstd",
paths,
- Long.MAX_VALUE);
+ Long.MAX_VALUE,
+ new CoreOptions(new Options()));
}
@Test
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarWriteTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarWriteTest.java
new file mode 100644
index 000000000000..cea952943f59
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestSidecarWriteTest.java
@@ -0,0 +1,347 @@
+/*
+ * 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.manifest;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.PositionOutputStreamWrapper;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.schema.FileSystemSchemaManager;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RowRangeIndex;
+
+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 javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiPredicate;
+import java.util.stream.Stream;
+
+import static org.apache.paimon.TestKeyValueGenerator.DEFAULT_PART_TYPE;
+import static org.apache.paimon.utils.VarLengthIntUtils.decodeInt;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests automatic generation and ownership of manifest sidecars. */
+class ManifestSidecarWriteTest {
+
+ @TempDir java.nio.file.Path temp;
+ private final ManifestTestDataGenerator gen = ManifestTestDataGenerator.builder().build();
+
+ @Test
+ void sidecarOptionControlsWriteIO() {
+ for (Boolean sort : new Boolean[] {null, false, true}) {
+ for (Boolean configured : new Boolean[] {null, false, true}) {
+ Options options = new Options();
+ if (sort != null) {
+ options.set(CoreOptions.MANIFEST_SORT_ENABLED, sort);
+ }
+ if (configured != null) {
+ options.set(CoreOptions.MANIFEST_SIDECAR_ENABLED, configured);
+ }
+ AtomicInteger reads = new AtomicInteger();
+ FileIO io =
+ new LocalFileIO() {
+
+ @Override
+ public SeekableInputStream newInputStream(Path path)
+ throws IOException {
+ reads.incrementAndGet();
+ return super.newInputStream(path);
+ }
+ };
+ Path root = root(sort + "-" + configured);
+ ManifestFile manifests =
+ manifests(root, io, DEFAULT_PART_TYPE, Long.MAX_VALUE, options);
+ ManifestFileMeta meta =
+ manifests.write(Collections.singletonList(gen.next())).get(0);
+ boolean enabled = configured == null ? Boolean.TRUE.equals(sort) : configured;
+ assertThat(reads.get()).isEqualTo(enabled ? 1 : 0);
+ assertThat(ManifestSidecar.fileName(meta) != null).isEqualTo(enabled);
+ }
+ }
+ }
+
+ @Test
+ void rollingAndRawRewritesGenerateTheirOwnSidecars() throws Exception {
+ Options options = enabledOptions();
+ options.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+ FileIO io = LocalFileIO.create();
+ Path root = root("rolling");
+ ManifestFile manifests = manifests(root, io, DEFAULT_PART_TYPE, 1, options);
+ List metas = manifests.write(entries(2200));
+ assertThat(metas.size()).isGreaterThan(1);
+ for (ManifestFileMeta meta : metas) {
+ assertThat(ManifestSidecar.fileName(meta))
+ .isEqualTo(meta.fileName() + ManifestSidecar.SUFFIX);
+ List actual = manifests.read(meta.fileName());
+ for (ManifestEntry entry :
+ Arrays.asList(actual.get(0), actual.get(actual.size() - 1))) {
+ long row = entry.file().firstRowId();
+ assertThat(select(root, io, meta, DEFAULT_PART_TYPE, point(row), null).blocks())
+ .isNotEmpty();
+ }
+ long gap = actual.get(0).file().firstRowId() + actual.get(0).file().rowCount();
+ assertThat(select(root, io, meta, DEFAULT_PART_TYPE, point(gap), null).blocks())
+ .isEmpty();
+ }
+
+ ManifestFileMeta source = metas.get(0);
+ Path rewrittenPath = new Path(new Path(root, "manifest"), "explicit-rewrite");
+ ManifestAvroWriter writer = manifests.createAvroWriter(rewrittenPath);
+ try (ManifestAvroReader reader =
+ manifests.scanAvroBlocks(source.fileName(), source.fileSize())) {
+ writer.writeEncodedManifest(reader, source);
+ }
+ assertThatThrownBy(writer::result).isInstanceOf(IllegalStateException.class);
+ writer.close();
+ ManifestFileMeta rewritten = writer.result().get(0);
+ assertThat(ManifestSidecar.fileName(rewritten))
+ .isEqualTo("explicit-rewrite" + ManifestSidecar.SUFFIX);
+ assertThat(manifests.read(rewritten.fileName()))
+ .isEqualTo(manifests.read(source.fileName()));
+ long outside = metas.get(metas.size() - 1).maxRowId();
+ assertThat(select(root, io, rewritten, DEFAULT_PART_TYPE, point(outside), null).blocks())
+ .isEmpty();
+ writer.abort();
+ assertThat(io.exists(rewrittenPath)).isFalse();
+ assertThat(io.exists(ManifestSidecar.path(rewrittenPath))).isFalse();
+ assertThat(io.exists(manifestPath(root, source))).isTrue();
+ for (ManifestFileMeta meta : metas) {
+ manifests.delete(meta);
+ assertThat(io.exists(ManifestSidecar.path(manifestPath(root, meta)))).isFalse();
+ }
+ }
+
+ @Test
+ void payloadsFollowTableMetadata() throws Exception {
+ for (boolean partitioned : new boolean[] {false, true}) {
+ for (boolean evolution : new boolean[] {false, true}) {
+ for (int bucket : new int[] {-2, -1, 4}) {
+ Options options = enabledOptions();
+ options.set(CoreOptions.DATA_EVOLUTION_ENABLED, evolution);
+ options.set(CoreOptions.BUCKET, bucket);
+ RowType partitionType = partitioned ? DEFAULT_PART_TYPE : RowType.of();
+ Path root = root(partitioned + "-" + evolution + "-" + bucket);
+ FileIO io = LocalFileIO.create();
+ ManifestFile manifests =
+ manifests(root, io, partitionType, Long.MAX_VALUE, options);
+ ManifestEntry source = gen.next();
+ ManifestEntry entry =
+ ManifestEntry.create(
+ FileKind.ADD,
+ partitioned ? source.partition() : BinaryRow.EMPTY_ROW,
+ 1,
+ 4,
+ source.file().newFirstRowId(100L));
+ ManifestFileMeta meta =
+ manifests.write(Collections.singletonList(entry)).get(0);
+ byte[] bytes =
+ Files.readAllBytes(
+ java.nio.file.Paths.get(
+ ManifestSidecar.path(manifestPath(root, meta))
+ .toString()));
+ ByteBuffer in = ByteBuffer.wrap(bytes);
+ in.getInt();
+ decodeInt(in);
+ int headerLength = decodeInt(in);
+ in.position(in.position() + headerLength);
+ assertThat(decodeInt(in)).isEqualTo(1);
+ assertThat(select(root, io, meta, partitionType, point(99), null).blocks())
+ .hasSize(evolution ? 0 : 1);
+ assertThat(
+ select(root, io, meta, partitionType, null, (b, t) -> b == 99)
+ .blocks())
+ .hasSize(bucket == -1 ? 1 : 0);
+ assertThat(manifests.read(meta.fileName())).containsExactly(entry);
+ }
+ }
+ }
+ }
+
+ @Test
+ void invalidRowIdRangeKeepsCoverageUnavailable() throws Exception {
+ FileIO io = LocalFileIO.create();
+ Path root = root("unknown");
+ ManifestFile manifests =
+ manifests(root, io, DEFAULT_PART_TYPE, Long.MAX_VALUE, enabledOptions());
+ ManifestEntry source = gen.next();
+ ManifestEntry entry =
+ ManifestEntry.create(
+ FileKind.ADD, source.partition(), 1, 4, source.file().newFirstRowId(-1L));
+ ManifestFileMeta meta = manifests.write(Collections.singletonList(entry)).get(0);
+ assertThat(meta.minRowId()).isNull();
+ assertThat(meta.maxRowId()).isNull();
+ assertThat(select(root, io, meta, DEFAULT_PART_TYPE, point(123), null).blocks()).hasSize(1);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"open", "write", "close"})
+ void sidecarFailureCleansAllRollingOutputs(String phase) throws Exception {
+ AtomicInteger sidecars = new AtomicInteger();
+ FileIO io =
+ new LocalFileIO() {
+
+ @Override
+ public PositionOutputStream newOutputStream(Path path, boolean overwrite)
+ throws IOException {
+ if (!path.getName().endsWith(ManifestSidecar.SUFFIX)
+ || sidecars.incrementAndGet() != 2) {
+ return super.newOutputStream(path, overwrite);
+ }
+ if (phase.equals("open")) {
+ throw new IOException("sidecar " + phase + " failed");
+ }
+ return new PositionOutputStreamWrapper(
+ super.newOutputStream(path, overwrite)) {
+
+ @Override
+ public void write(byte[] bytes) throws IOException {
+ if (phase.equals("write")) {
+ throw new IOException("sidecar " + phase + " failed");
+ }
+ super.write(bytes);
+ }
+
+ @Override
+ public void close() throws IOException {
+ super.close();
+ if (phase.equals("close")) {
+ throw new IOException("sidecar " + phase + " failed");
+ }
+ }
+ };
+ }
+ };
+ Path root = root("failure-" + phase);
+ ManifestFile manifests = manifests(root, io, DEFAULT_PART_TYPE, 1, enabledOptions());
+ assertThatThrownBy(() -> manifests.write(entries(2200)))
+ .hasRootCauseMessage("sidecar " + phase + " failed");
+ try (Stream files =
+ Files.list(temp.resolve("failure-" + phase).resolve("manifest"))) {
+ assertThat(files).isEmpty();
+ }
+ }
+
+ private Options enabledOptions() {
+ Options options = new Options();
+ options.set(CoreOptions.MANIFEST_SIDECAR_ENABLED, true);
+ options.set(CoreOptions.DATA_EVOLUTION_ENABLED, true);
+ return options;
+ }
+
+ private List entries(int count) {
+ List result = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ ManifestEntry entry = gen.next();
+ result.add(
+ ManifestEntry.create(
+ i % 2 == 0 ? FileKind.ADD : FileKind.DELETE,
+ entry.partition(),
+ entry.bucket(),
+ entry.totalBuckets(),
+ entry.file().newFirstRowId(i * 100000000L)));
+ }
+ return result;
+ }
+
+ private Path root(String name) {
+ return new Path(temp.resolve(name).toString());
+ }
+
+ private static RowRangeIndex point(long rowId) {
+ return RowRangeIndex.create(Collections.singletonList(new Range(rowId, rowId)));
+ }
+
+ private static Path manifestPath(Path root, ManifestFileMeta meta) {
+ return new Path(new Path(root, "manifest"), meta.fileName());
+ }
+
+ private ManifestSidecar.Selection select(
+ Path root,
+ FileIO io,
+ ManifestFileMeta meta,
+ RowType partitionType,
+ @Nullable RowRangeIndex rows,
+ @Nullable BiPredicate buckets) {
+ ManifestSidecar.Selection result =
+ ManifestSidecar.read(
+ io,
+ manifestPath(root, meta),
+ meta,
+ rows,
+ null,
+ partitionType,
+ buckets,
+ null);
+ assertThat(result).isNotNull();
+ return result;
+ }
+
+ private ManifestFile manifests(
+ Path root, FileIO io, RowType partitionType, long targetSize, Options options) {
+ FileStorePathFactory paths =
+ new FileStorePathFactory(
+ root,
+ partitionType,
+ "default",
+ CoreOptions.FILE_FORMAT.defaultValue(),
+ CoreOptions.DATA_FILE_PREFIX.defaultValue(),
+ CoreOptions.CHANGELOG_FILE_PREFIX.defaultValue(),
+ CoreOptions.PARTITION_GENERATE_LEGACY_NAME.defaultValue(),
+ CoreOptions.FILE_SUFFIX_INCLUDE_COMPRESSION.defaultValue(),
+ CoreOptions.FILE_COMPRESSION.defaultValue(),
+ null,
+ null,
+ CoreOptions.ExternalPathStrategy.NONE,
+ null,
+ false,
+ null);
+ return new ManifestFile.Factory(
+ io,
+ new FileSystemSchemaManager(io, root),
+ partitionType,
+ FileFormat.fromIdentifier("avro", new Options()),
+ "zstd",
+ paths,
+ targetSize,
+ null,
+ new CoreOptions(options))
+ .create();
+ }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
index 8247d23262a7..20a1b26beece 100644
--- a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
@@ -668,7 +668,8 @@ private ManifestFile createManifestFile(long suggestedFileSize) {
false,
null),
suggestedFileSize,
- null)
+ null,
+ new CoreOptions(new Options()))
.create();
}