diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html
index 0776eaf9e369..907f7512002f 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1681,6 +1681,12 @@
Boolean |
Whether to enable tag expiration by retained time. |
+
+ target-file-num-rows |
+ 9223372036854775807 |
+ Long |
+ Target number of rows per newly written data file; a file rolls when this or target-file-size is reached, whichever comes first. Enforced at bundle granularity, so a bundled write may exceed it by up to one bundle. Only constrains files at write time: compaction is size-based and may merge into larger files, and data-evolution compaction still produces a single file. Bounds per-file rows for wide columns to avoid data-evolution OOM. Disabled by default. |
+
target-file-size |
(none) |
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 ec6a2296bf9a..2a566a591610 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -818,6 +818,20 @@ public InlineElement getDescription() {
text("append table: the default value is 256 MB."))
.build());
+ public static final ConfigOption TARGET_FILE_NUM_ROWS =
+ key("target-file-num-rows")
+ .longType()
+ .defaultValue(Long.MAX_VALUE)
+ .withDescription(
+ "Target number of rows per newly written data file; a file rolls when "
+ + "this or target-file-size is reached, whichever comes first. "
+ + "Enforced at bundle granularity, so a bundled write may exceed it "
+ + "by up to one bundle. Only constrains files at write time: "
+ + "compaction is size-based and may merge into larger files, and "
+ + "data-evolution compaction still produces a single file. Bounds "
+ + "per-file rows for wide columns to avoid data-evolution OOM. "
+ + "Disabled by default.");
+
public static final ConfigOption COMPACTION_SMALL_FILE_RATIO =
key("compaction.small-file-ratio")
.doubleType()
@@ -3325,6 +3339,10 @@ public long targetFileSize(boolean hasPrimaryKey) {
.getBytes();
}
+ public long targetFileNumRows() {
+ return options.get(TARGET_FILE_NUM_ROWS);
+ }
+
public long blobTargetFileSize() {
return options.getOptional(BLOB_TARGET_FILE_SIZE)
.map(MemorySize::getBytes)
diff --git a/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java b/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java
index ea81dea50f60..9ad51c552418 100644
--- a/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java
@@ -77,6 +77,7 @@ public class AppendOnlyWriter implements BatchRecordWriter, MemoryOwner {
private final long targetFileSize;
private final long blobTargetFileSize;
private final long vectorTargetFileSize;
+ private final long targetFileNumRows;
private final RowType writeSchema;
@Nullable private final List writeCols;
private final DataFilePathFactory pathFactory;
@@ -103,6 +104,7 @@ public class AppendOnlyWriter implements BatchRecordWriter, MemoryOwner {
private SinkWriter sinkWriter;
private MemorySegmentPool memorySegmentPool;
+ /** Overload without a row limit, kept for backward compatibility. */
public AppendOnlyWriter(
FileIO fileIO,
@Nullable IOManager ioManager,
@@ -132,6 +134,68 @@ public AppendOnlyWriter(
boolean dataEvolutionEnabled,
@Nullable FileFormat rowSidecarFileFormat,
@Nullable BlobFileContext blobContext) {
+ this(
+ fileIO,
+ ioManager,
+ schemaId,
+ fileFormat,
+ vectorFileFormat,
+ targetFileSize,
+ blobTargetFileSize,
+ vectorTargetFileSize,
+ Long.MAX_VALUE,
+ writeSchema,
+ writeCols,
+ maxSequenceNumber,
+ compactManager,
+ dataFileRead,
+ forceCompact,
+ pathFactory,
+ increment,
+ useWriteBuffer,
+ spillable,
+ fileCompression,
+ spillCompression,
+ statsCollectorFactories,
+ maxDiskSize,
+ fileIndexOptions,
+ asyncFileWrite,
+ statsDenseStore,
+ dataEvolutionEnabled,
+ rowSidecarFileFormat,
+ blobContext);
+ }
+
+ public AppendOnlyWriter(
+ FileIO fileIO,
+ @Nullable IOManager ioManager,
+ long schemaId,
+ FileFormat fileFormat,
+ @Nullable FileFormat vectorFileFormat,
+ long targetFileSize,
+ long blobTargetFileSize,
+ long vectorTargetFileSize,
+ long targetFileNumRows,
+ RowType writeSchema,
+ @Nullable List writeCols,
+ long maxSequenceNumber,
+ CompactManager compactManager,
+ IOFunction, RecordReaderIterator> dataFileRead,
+ boolean forceCompact,
+ DataFilePathFactory pathFactory,
+ @Nullable CommitIncrement increment,
+ boolean useWriteBuffer,
+ boolean spillable,
+ String fileCompression,
+ CompressOptions spillCompression,
+ StatsCollectorFactories statsCollectorFactories,
+ MemorySize maxDiskSize,
+ FileIndexOptions fileIndexOptions,
+ boolean asyncFileWrite,
+ boolean statsDenseStore,
+ boolean dataEvolutionEnabled,
+ @Nullable FileFormat rowSidecarFileFormat,
+ @Nullable BlobFileContext blobContext) {
this.fileIO = fileIO;
this.schemaId = schemaId;
this.fileFormat = fileFormat;
@@ -139,6 +203,7 @@ public AppendOnlyWriter(
this.targetFileSize = targetFileSize;
this.blobTargetFileSize = blobTargetFileSize;
this.vectorTargetFileSize = vectorTargetFileSize;
+ this.targetFileNumRows = targetFileNumRows;
this.writeSchema = writeSchema;
this.writeCols = writeCols;
this.pathFactory = pathFactory;
@@ -325,6 +390,7 @@ private RollingFileWriter createRollingRowWriter() {
targetFileSize,
blobTargetFileSize,
vectorTargetFileSize,
+ targetFileNumRows,
writeSchema,
pathFactory,
seqNumCounterProvider,
@@ -350,7 +416,8 @@ private RollingFileWriter createRollingRowWriter() {
asyncFileWrite,
statsDenseStore,
writeCols,
- rowSidecarFileFormat);
+ rowSidecarFileFormat,
+ targetFileNumRows);
}
private void trySyncLatestCompaction(boolean blocking)
diff --git a/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java b/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
index 9b8897862a8b..ac8d68fb00ae 100644
--- a/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
@@ -109,6 +109,7 @@ public class DedicatedFormatRollingFileWriter
RollingFileWriterImpl, List>>
vectorStoreWriterFactory;
private final long targetFileSize;
+ private final long targetFileNumRows;
// State management
private final List closedWriters;
@@ -121,8 +122,10 @@ public class DedicatedFormatRollingFileWriter
RollingFileWriterImpl, List>
vectorStoreWriter;
private long recordCount = 0;
+ private long currentFileRecordCount = 0;
private boolean closed = false;
+ /** Overload without a row limit, kept for backward compatibility. */
public DedicatedFormatRollingFileWriter(
FileIO fileIO,
long schemaId,
@@ -140,8 +143,51 @@ public DedicatedFormatRollingFileWriter(
FileSource fileSource,
boolean statsDenseStore,
@Nullable BlobFileContext context) {
+ this(
+ fileIO,
+ schemaId,
+ fileFormat,
+ vectorFileFormat,
+ targetFileSize,
+ blobTargetFileSize,
+ vectorTargetFileSize,
+ Long.MAX_VALUE,
+ writeSchema,
+ pathFactory,
+ seqNumCounterSupplier,
+ fileCompression,
+ statsCollectorFactories,
+ fileIndexOptions,
+ fileSource,
+ statsDenseStore,
+ context);
+ }
+
+ public DedicatedFormatRollingFileWriter(
+ FileIO fileIO,
+ long schemaId,
+ FileFormat fileFormat,
+ @Nullable FileFormat vectorFileFormat,
+ long targetFileSize,
+ long blobTargetFileSize,
+ long vectorTargetFileSize,
+ long targetFileNumRows,
+ RowType writeSchema,
+ DataFilePathFactory pathFactory,
+ Supplier seqNumCounterSupplier,
+ String fileCompression,
+ StatsCollectorFactories statsCollectorFactories,
+ FileIndexOptions fileIndexOptions,
+ FileSource fileSource,
+ boolean statsDenseStore,
+ @Nullable BlobFileContext context) {
// Initialize basic fields
+ Preconditions.checkArgument(
+ targetFileNumRows > 0,
+ "targetFileNumRows must be positive, but is %s",
+ targetFileNumRows);
this.targetFileSize = targetFileSize;
+ this.targetFileNumRows = targetFileNumRows;
this.results = new ArrayList<>();
this.closedWriters = new ArrayList<>();
@@ -352,8 +398,9 @@ public void write(InternalRow row) throws IOException {
currentWriter.write(row);
}
recordCount++;
+ currentFileRecordCount++;
- if (currentWriter != null && rollingFile()) {
+ if (rollingFile()) {
closeCurrentWriter();
}
} catch (Throwable e) {
@@ -416,11 +463,19 @@ public void abort() {
}
}
- /** Checks if the current file should be rolled based on size and record count. */
+ /**
+ * Checks if the current file should be rolled. The row cap applies even when there is no main
+ * writer (all fields dedicated), so blob/vector writers roll together.
+ */
private boolean rollingFile() throws IOException {
- return currentWriter
- .writer()
- .reachTargetSize(recordCount % CHECK_ROLLING_RECORD_CNT == 0, targetFileSize);
+ if (currentFileRecordCount >= targetFileNumRows) {
+ return true;
+ }
+ return currentWriter != null
+ && currentWriter
+ .writer()
+ .reachTargetSize(
+ recordCount % CHECK_ROLLING_RECORD_CNT == 0, targetFileSize);
}
/**
@@ -455,6 +510,7 @@ private void closeCurrentWriter() throws IOException {
// Reset current writer
currentWriter = null;
+ currentFileRecordCount = 0;
}
/** Closes the main writer and returns its metadata. */
diff --git a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
index 77c3eaa91528..5826d7b8e0cf 100644
--- a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
+++ b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
@@ -61,6 +61,8 @@ private static Map dynamicWriteOptions() {
Map options = new HashMap<>();
options.put(CoreOptions.TARGET_FILE_SIZE.key(), "99999 G");
options.put(CoreOptions.BLOB_TARGET_FILE_SIZE.key(), "99999 G");
+ // Data evolution requires a single output file, so the row limit must not roll it either.
+ options.put(CoreOptions.TARGET_FILE_NUM_ROWS.key(), String.valueOf(Long.MAX_VALUE));
return Collections.unmodifiableMap(options);
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java
index dfe875120dcf..bc8701bf9377 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java
@@ -136,13 +136,17 @@ public DataFilePathFactory pathFactory(int level) {
public RollingFileWriter createRollingMergeTreeFileWriter(
int level, FileSource fileSource) {
WriteFormatKey key = new WriteFormatKey(level, false);
+ // Row limit applies to writes only; compaction output stays size-only.
+ long targetFileNumRows =
+ fileSource == FileSource.COMPACT ? Long.MAX_VALUE : options.targetFileNumRows();
return new RollingFileWriterImpl<>(
() -> {
DataFilePathFactory pathFactory = formatContext.pathFactory(key);
return createDataFileWriter(
pathFactory.newPath(), key, fileSource, pathFactory.isExternalPath());
},
- suggestedFileSize);
+ suggestedFileSize,
+ targetFileNumRows);
}
public RollingFileWriter createRollingChangelogFileWriter(int level) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java b/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
index 299a4f97421e..31b16d50f570 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
@@ -41,17 +41,31 @@ public class RollingFileWriterImpl implements RollingFileWriter {
private final Supplier extends SingleFileWriter> writerFactory;
private final long targetFileSize;
+ private final long targetFileNumRows;
private final List closedWriters;
protected final List results;
private SingleFileWriter currentWriter = null;
private long recordCount = 0;
+ private long currentFileRecordCount = 0;
private boolean closed = false;
public RollingFileWriterImpl(
Supplier extends SingleFileWriter> writerFactory, long targetFileSize) {
+ this(writerFactory, targetFileSize, Long.MAX_VALUE);
+ }
+
+ public RollingFileWriterImpl(
+ Supplier extends SingleFileWriter> writerFactory,
+ long targetFileSize,
+ long targetFileNumRows) {
+ Preconditions.checkArgument(
+ targetFileNumRows > 0,
+ "targetFileNumRows must be positive, but is %s",
+ targetFileNumRows);
this.writerFactory = writerFactory;
this.targetFileSize = targetFileSize;
+ this.targetFileNumRows = targetFileNumRows;
this.results = new ArrayList<>();
this.closedWriters = new ArrayList<>();
}
@@ -62,8 +76,9 @@ public long targetFileSize() {
}
private boolean rollingFile(boolean forceCheck) throws IOException {
- return currentWriter.reachTargetSize(
- forceCheck || recordCount % CHECK_ROLLING_RECORD_CNT == 0, targetFileSize);
+ return currentFileRecordCount >= targetFileNumRows
+ || currentWriter.reachTargetSize(
+ forceCheck || recordCount % CHECK_ROLLING_RECORD_CNT == 0, targetFileSize);
}
@Override
@@ -76,6 +91,7 @@ public void write(T row) throws IOException {
currentWriter.write(row);
recordCount += 1;
+ currentFileRecordCount += 1;
if (rollingFile(false)) {
closeCurrentWriter();
@@ -101,6 +117,7 @@ public void writeBundle(BundleRecords bundle) throws IOException {
currentWriter.writeBundle(bundle);
recordCount += bundle.rowCount();
+ currentFileRecordCount += bundle.rowCount();
if (rollingFile(true)) {
closeCurrentWriter();
@@ -132,6 +149,7 @@ protected void closeCurrentWriter() throws IOException {
currentWriter.abortExecutor().ifPresent(closedWriters::add);
results.add(currentWriter.result());
currentWriter = null;
+ currentFileRecordCount = 0;
}
@Override
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java b/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java
index 0da1badf6229..bbaf7790974b 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java
@@ -52,6 +52,42 @@ public RowDataRollingFileWriter(
boolean statsDenseStore,
@Nullable List writeCols,
@Nullable FileFormat rowSidecarFormat) {
+ this(
+ fileIO,
+ schemaId,
+ fileFormat,
+ targetFileSize,
+ writeSchema,
+ pathFactory,
+ seqNumCounterSupplier,
+ fileCompression,
+ statsCollectors,
+ fileIndexOptions,
+ fileSource,
+ asyncFileWrite,
+ statsDenseStore,
+ writeCols,
+ rowSidecarFormat,
+ Long.MAX_VALUE);
+ }
+
+ public RowDataRollingFileWriter(
+ FileIO fileIO,
+ long schemaId,
+ FileFormat fileFormat,
+ long targetFileSize,
+ RowType writeSchema,
+ DataFilePathFactory pathFactory,
+ Supplier seqNumCounterSupplier,
+ String fileCompression,
+ SimpleColStatsCollector.Factory[] statsCollectors,
+ FileIndexOptions fileIndexOptions,
+ FileSource fileSource,
+ boolean asyncFileWrite,
+ boolean statsDenseStore,
+ @Nullable List writeCols,
+ @Nullable FileFormat rowSidecarFormat,
+ long targetFileNumRows) {
super(
() -> {
Path dataPath = pathFactory.newPath();
@@ -76,6 +112,7 @@ public RowDataRollingFileWriter(
rowSidecarFormat,
rowSidecarPath);
},
- targetFileSize);
+ targetFileSize,
+ targetFileNumRows);
}
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java b/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
index 7dceb12e5c8b..45cdf5039ddc 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
@@ -160,6 +160,7 @@ protected RecordWriter createWriter(
options.targetFileSize(false),
options.blobTargetFileSize(),
options.vectorTargetFileSize(),
+ options.targetFileNumRows(),
writeType,
writeCols,
restoredMaxSeqNumber,
diff --git a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index 4914ef1b40f5..4383771dc62e 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -191,6 +191,10 @@ public static void validateTableSchema(TableSchema schema, Set dynamicOp
FULL_COMPACTION_DELTA_COMMITS.key()));
}
+ checkArgument(
+ options.targetFileNumRows() > 0,
+ CoreOptions.TARGET_FILE_NUM_ROWS.key() + " should be at least 1");
+
checkArgument(
options.snapshotNumRetainMin() > 0,
SNAPSHOT_NUM_RETAINED_MIN.key() + " should be at least 1");
diff --git a/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java b/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java
index bd5d1d2cf54f..45f2a8a4b15f 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java
@@ -1028,6 +1028,7 @@ private AppendOnlyWriter createSharedShreddingAppendWriter(
1024 * 1024L,
1024 * 1024L,
1024 * 1024L,
+ Long.MAX_VALUE,
writeType,
null,
maxSequenceNumber,
@@ -1238,6 +1239,7 @@ private Pair> createWriterBase(
targetFileSize,
targetFileSize,
targetFileSize,
+ Long.MAX_VALUE,
writeSchema,
null,
getMaxSequenceNumber(toCompact),
diff --git a/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java b/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
index dbe9413804c6..b826800a889b 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
@@ -147,6 +147,40 @@ public void testMultipleWrites() throws IOException {
metasResult.subList(1, 4).stream().mapToLong(DataFileMeta::rowCount).sum());
}
+ @Test
+ public void testRollingByRows() throws IOException {
+ CoreOptions options = new CoreOptions(new Options());
+ long hugeSize = 1024L * 1024 * 1024;
+ DedicatedFormatRollingFileWriter cappedWriter =
+ new DedicatedFormatRollingFileWriter(
+ LocalFileIO.create(),
+ SCHEMA_ID,
+ FileFormat.fromIdentifier("parquet", new Options()),
+ null,
+ hugeSize,
+ hugeSize,
+ hugeSize,
+ 5L,
+ SCHEMA,
+ pathFactory,
+ LongCounter::new,
+ COMPRESSION,
+ new StatsCollectorFactories(options),
+ new FileIndexOptions(),
+ FileSource.APPEND,
+ false,
+ BlobFileContext.create(SCHEMA, options));
+ for (int i = 0; i < 11; i++) {
+ cappedWriter.write(
+ GenericRow.of(i, BinaryString.fromString("t" + i), new BlobData(testBlobData)));
+ }
+ cappedWriter.close();
+ assertThat(cappedWriter.result())
+ .filteredOn(f -> "parquet".equals(f.fileFormat()))
+ .extracting(DataFileMeta::rowCount)
+ .containsExactly(5L, 5L, 1L);
+ }
+
@Test
public void testDoesNotWriteRowSidecar() throws IOException {
// Tests that: dedicated blob files do not create row-store sidecars.
diff --git a/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java b/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java
index 4b5c73038664..fa51b5732882 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java
@@ -178,6 +178,29 @@ public void testWriteAndReadDataFileWithFileExtractingRollingFile() throws Excep
testWriteAndReadDataFileImpl("avro-extract");
}
+ @Test
+ public void testMergeTreeCompactionIgnoresRowLimit() throws Exception {
+ Options options = new Options();
+ options.set(CoreOptions.TARGET_FILE_NUM_ROWS, 2L);
+ KeyValueFileWriterFactory writerFactory =
+ createWriterFactory(tempDir.toString(), "avro", options);
+ List content = gen.next().content;
+
+ RollingFileWriter appendWriter =
+ writerFactory.createRollingMergeTreeFileWriter(0, FileSource.APPEND);
+ appendWriter.write(CloseableIterator.fromList(content, kv -> {}));
+ appendWriter.close();
+
+ RollingFileWriter compactWriter =
+ writerFactory.createRollingMergeTreeFileWriter(0, FileSource.COMPACT);
+ compactWriter.write(CloseableIterator.fromList(content, kv -> {}));
+ compactWriter.close();
+
+ // Writes roll by rows (cap=2); compaction output is size-only, so it is not row-split.
+ assertThat(appendWriter.result().size()).isGreaterThan(1);
+ assertThat(compactWriter.result()).hasSize(1);
+ }
+
private void testWriteAndReadDataFileImpl(String format) throws Exception {
DataFileTestDataGenerator.Data data = gen.next();
KeyValueFileWriterFactory writerFactory = createWriterFactory(tempDir.toString(), format);
@@ -341,6 +364,7 @@ public void testFileSuffix(@TempDir java.nio.file.Path tempDir) throws Exception
10,
10,
10,
+ Long.MAX_VALUE,
schema,
null,
0,
diff --git a/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java b/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
index ec334007e507..b7ac286b08fd 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
@@ -72,6 +72,14 @@ public void initialize(String identifier) {
}
public void initialize(String identifier, boolean statsDenseStore) {
+ initialize(identifier, statsDenseStore, TARGET_FILE_SIZE, Long.MAX_VALUE);
+ }
+
+ public void initialize(
+ String identifier,
+ boolean statsDenseStore,
+ long targetFileSize,
+ long targetFileNumRows) {
FileFormat fileFormat = FileFormat.fromIdentifier(identifier, new Options());
rollingFileWriter =
new RollingFileWriterImpl<>(
@@ -106,7 +114,8 @@ public void initialize(String identifier, boolean statsDenseStore) {
statsDenseStore,
false,
null),
- TARGET_FILE_SIZE);
+ targetFileSize,
+ targetFileNumRows);
}
@ParameterizedTest
@@ -129,6 +138,45 @@ public void testRolling(String formatType) throws IOException {
}
}
+ @Test
+ public void testRollingByRows() throws IOException {
+ // Huge byte target so only the row-count limit triggers rolling.
+ initialize("avro", false, 1024L * 1024 * 1024, 100L);
+ for (int i = 0; i < 350; i++) {
+ rollingFileWriter.write(GenericRow.of(i));
+ }
+ rollingFileWriter.close();
+ List files = rollingFileWriter.result();
+ assertThat(files).hasSize(4);
+ assertThat(files.get(0).rowCount()).isEqualTo(100);
+ assertThat(files.get(1).rowCount()).isEqualTo(100);
+ assertThat(files.get(2).rowCount()).isEqualTo(100);
+ assertThat(files.get(3).rowCount()).isEqualTo(50);
+ }
+
+ @Test
+ public void testRollingByRowsWithBundle() throws IOException {
+ // Bundle-granular cap: a file may overshoot by up to one bundle.
+ initialize("avro", false, 1024L * 1024 * 1024, 100L);
+ rollingFileWriter.writeBundle(bundle(150));
+ rollingFileWriter.writeBundle(bundle(120));
+ rollingFileWriter.writeBundle(bundle(30));
+ rollingFileWriter.close();
+ List files = rollingFileWriter.result();
+ assertThat(files).hasSize(3);
+ assertThat(files.get(0).rowCount()).isEqualTo(150);
+ assertThat(files.get(1).rowCount()).isEqualTo(120);
+ assertThat(files.get(2).rowCount()).isEqualTo(30);
+ }
+
+ private static BundleRecords bundle(int rowCount) {
+ List rows = new ArrayList<>();
+ for (int i = 0; i < rowCount; i++) {
+ rows.add(GenericRow.of(i));
+ }
+ return new SingleUseBundleRecords(rows);
+ }
+
@Test
public void testWriteRowSidecar() throws IOException {
FileFormat fileFormat = FileFormat.fromIdentifier("parquet", new Options());
diff --git a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
index 36b8668bcc1f..78ccf0c5c69c 100644
--- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
@@ -112,6 +112,14 @@ public void testFromTimestampConflict() {
"must set only one key in [scan.timestamp-millis,scan.timestamp] when you use from-timestamp for scan.mode");
}
+ @Test
+ public void testTargetFileNumRowsMustBePositive() {
+ Map options = new HashMap<>();
+ options.put(CoreOptions.TARGET_FILE_NUM_ROWS.key(), "0");
+ assertThatThrownBy(() -> validateTableSchemaExec(options))
+ .hasMessageContaining("target-file-num-rows should be at least 1");
+ }
+
@Test
public void testFromSnapshotConflict() {
String timestampString =
diff --git a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
index 7766da98de24..b7db5b80e864 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
@@ -1126,6 +1126,90 @@ public void testCompactCoordinator() throws Exception {
assertThat(task.compactBefore().size()).isEqualTo(20);
}
+ @Test
+ public void testDataEvolutionReadWithRolledColumns() throws Exception {
+ createTableDefault();
+ FileStoreTable table =
+ getTableDefault()
+ .copy(
+ Collections.singletonMap(
+ CoreOptions.TARGET_FILE_NUM_ROWS.key(), "100"));
+ int count = 350;
+ RowType wt0 = table.schema().logicalRowType().project(Arrays.asList("f0", "f1"));
+ BatchWriteBuilder b0 = table.newBatchWriteBuilder();
+ try (BatchTableWrite w0 = b0.newWrite().withWriteType(wt0)) {
+ for (int i = 0; i < count; i++) {
+ w0.write(GenericRow.of(i, BinaryString.fromString("a" + i)));
+ }
+ b0.newCommit().commit(w0.prepareCommit());
+ }
+ long firstRowId = table.snapshotManager().latestSnapshot().nextRowId() - count;
+
+ RowType wt1 = table.schema().logicalRowType().project(Collections.singletonList("f2"));
+ BatchWriteBuilder b1 = table.newBatchWriteBuilder();
+ try (BatchTableWrite w1 = b1.newWrite().withWriteType(wt1)) {
+ for (int i = 0; i < count; i++) {
+ w1.write(GenericRow.of(BinaryString.fromString("b" + i)));
+ }
+ List msgs = w1.prepareCommit();
+ assignCumulativeFirstRowId(msgs, firstRowId);
+ b1.newCommit().commit(msgs);
+ }
+
+ ReadBuilder rb = table.newReadBuilder();
+ RecordReader reader = rb.newRead().createReader(rb.newScan().plan());
+ AtomicInteger cnt = new AtomicInteger(0);
+ reader.forEachRemaining(
+ r -> {
+ int i = r.getInt(0);
+ assertThat(r.getString(1).toString()).isEqualTo("a" + i);
+ assertThat(r.getString(2).toString()).isEqualTo("b" + i);
+ cnt.incrementAndGet();
+ });
+ assertThat(cnt.get()).isEqualTo(count);
+ }
+
+ private void assignCumulativeFirstRowId(List msgs, long firstRowId) {
+ long cur = firstRowId;
+ for (CommitMessage c : msgs) {
+ CommitMessageImpl m = (CommitMessageImpl) c;
+ List files = new ArrayList<>(m.newFilesIncrement().newFiles());
+ m.newFilesIncrement().newFiles().clear();
+ List assigned = new ArrayList<>();
+ for (DataFileMeta f : files) {
+ assigned.add(f.assignFirstRowId(cur));
+ cur += f.rowCount();
+ }
+ m.newFilesIncrement().newFiles().addAll(assigned);
+ }
+ }
+
+ @Test
+ public void testDataEvolutionWriteRollsByRows() throws Exception {
+ createTableDefault();
+ FileStoreTable table =
+ getTableDefault()
+ .copy(
+ Collections.singletonMap(
+ CoreOptions.TARGET_FILE_NUM_ROWS.key(), "100"));
+ RowType writeType = table.schema().logicalRowType().project(Arrays.asList("f0", "f1"));
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite().withWriteType(writeType)) {
+ for (int i = 0; i < 350; i++) {
+ write.write(GenericRow.of(i, BinaryString.fromString("a" + i)));
+ }
+ builder.newCommit().commit(write.prepareCommit());
+ }
+
+ List rowCounts = new ArrayList<>();
+ Iterator files = table.newSnapshotReader().readFileIterator();
+ while (files.hasNext()) {
+ rowCounts.add(files.next().file().rowCount());
+ }
+ assertThat(rowCounts.stream().mapToLong(Long::longValue).sum()).isEqualTo(350L);
+ assertThat(Collections.max(rowCounts)).isLessThanOrEqualTo(100L);
+ }
+
@Test
public void testCompact() throws Exception {
for (int i = 0; i < 5; i++) {
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionPartialWriteOperator.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionPartialWriteOperator.java
index f57393b7ca27..f3cd4c6f9b9c 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionPartialWriteOperator.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionPartialWriteOperator.java
@@ -92,10 +92,17 @@ public class DataEvolutionPartialWriteOperator
private transient AbstractFileStoreWrite tableWrite;
private transient Writer writer;
+ private static Map dataEvolutionWriteOptions() {
+ // Data evolution requires a single output file: disable both size and row rolling.
+ Map options = new HashMap<>();
+ options.put(CoreOptions.TARGET_FILE_SIZE.key(), "99999 G");
+ options.put(CoreOptions.TARGET_FILE_NUM_ROWS.key(), String.valueOf(Long.MAX_VALUE));
+ return options;
+ }
+
public DataEvolutionPartialWriteOperator(
FileStoreTable table, RowType dataType, Long baseSnapshotId) {
- this.table =
- table.copy(Collections.singletonMap(CoreOptions.TARGET_FILE_SIZE.key(), "99999 G"));
+ this.table = table.copy(dataEvolutionWriteOptions());
this.baseSnapshotId = baseSnapshotId;
List fieldNames =
dataType.getFieldNames().stream()
diff --git a/paimon-python/pypaimon/tests/reader_append_only_test.py b/paimon-python/pypaimon/tests/reader_append_only_test.py
index 67bbfbedbdec..181a828f9801 100644
--- a/paimon-python/pypaimon/tests/reader_append_only_test.py
+++ b/paimon-python/pypaimon/tests/reader_append_only_test.py
@@ -71,6 +71,17 @@ def test_parquet_ao_reader(self):
actual = self._read_test_table(read_builder).sort_by('user_id')
self.assertEqual(actual, self.expected)
+ def test_target_file_num_rows_fails_fast(self):
+ schema = Schema.from_pyarrow_schema(
+ self.pa_schema, partition_keys=['dt'],
+ options={'target-file-num-rows': '5'})
+ self.catalog.create_table('default.test_target_file_num_rows', schema, False)
+ table = self.catalog.get_table('default.test_target_file_num_rows')
+ with self.assertRaises(NotImplementedError):
+ write_builder = table.new_batch_write_builder()
+ table_write = write_builder.new_write()
+ table_write.write_arrow(self.expected)
+
def test_orc_ao_reader(self):
schema = Schema.from_pyarrow_schema(self.pa_schema, partition_keys=['dt'], options={'file.format': 'orc'})
self.catalog.create_table('default.test_append_only_orc', schema, False)
diff --git a/paimon-python/pypaimon/write/file_store_write.py b/paimon-python/pypaimon/write/file_store_write.py
index 0895c47bf17e..edaadf7560cb 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -49,6 +49,12 @@ def __init__(self, table, commit_user):
self.blob_consumer = None
self.commit_identifier = 0
self.options = CoreOptions.copy(table.options)
+ # pypaimon has no row-count rolling yet; fail fast instead of silently ignoring the option.
+ row_cap = table.table_schema.options.get("target-file-num-rows")
+ if row_cap is not None and str(row_cap).strip() not in ("", str(2 ** 63 - 1)):
+ raise NotImplementedError(
+ "target-file-num-rows is set on this table but pypaimon writers do not support "
+ "row-count based file rolling yet; unset it or write with Java/Flink/Spark.")
self.changelog_producer = self.options.changelog_producer()
if self.table.bucket_mode() == BucketMode.POSTPONE_MODE:
self.options.set(CoreOptions.DATA_FILE_PREFIX,
diff --git a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionPaimonWriter.scala b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionPaimonWriter.scala
index e99afc95b6e0..01e787a7d19b 100644
--- a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionPaimonWriter.scala
+++ b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionPaimonWriter.scala
@@ -31,17 +31,19 @@ import org.apache.paimon.utils.SerializationUtils
import org.apache.spark.sql._
import org.apache.spark.sql.catalyst.analysis.SimpleAnalyzer.resolver
-import java.util.Collections
-
import scala.collection.JavaConverters._
import scala.collection.mutable
case class DataEvolutionPaimonWriter(paimonTable: FileStoreTable, dataSplits: Seq[DataSplit])
extends WriteHelper {
- // File rolling will never be performed
- override val table: FileStoreTable =
- paimonTable.copy(Collections.singletonMap(CoreOptions.TARGET_FILE_SIZE.key(), "99999 G"))
+ // File rolling will never be performed: disable both size and row rolling.
+ override val table: FileStoreTable = {
+ val writeOptions = Map(
+ CoreOptions.TARGET_FILE_SIZE.key() -> "99999 G",
+ CoreOptions.TARGET_FILE_NUM_ROWS.key() -> Long.MaxValue.toString)
+ paimonTable.copy(writeOptions.asJava)
+ }
def writePartialFields(
data: DataFrame,