This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new a948c40  feat: add file writer infrastructure and data/compact 
increment models (#81)
a948c40 is described below

commit a948c40095f5a2b084b2854956479138a7c30872
Author: Yonghao Fang <[email protected]>
AuthorDate: Tue Jun 16 09:11:54 2026 +0800

    feat: add file writer infrastructure and data/compact increment models (#81)
---
 src/paimon/core/io/compact_increment.h             | 156 +++++++++++++
 src/paimon/core/io/compact_increment_test.cpp      | 132 +++++++++++
 src/paimon/core/io/data_file_writer.cpp            |  83 +++++++
 src/paimon/core/io/data_file_writer.h              |  70 ++++++
 src/paimon/core/io/data_increment.h                | 158 ++++++++++++++
 src/paimon/core/io/data_increment_test.cpp         | 107 +++++++++
 src/paimon/core/io/file_writer.h                   |  63 ++++++
 src/paimon/core/io/key_value_data_file_writer.cpp  | 203 +++++++++++++++++
 src/paimon/core/io/key_value_data_file_writer.h    |  89 ++++++++
 src/paimon/core/io/rolling_blob_file_writer.cpp    | 198 +++++++++++++++++
 src/paimon/core/io/rolling_blob_file_writer.h      |  94 ++++++++
 .../core/io/rolling_blob_file_writer_test.cpp      |  94 ++++++++
 src/paimon/core/io/rolling_file_writer.h           | 199 +++++++++++++++++
 src/paimon/core/io/single_file_writer.h            | 241 +++++++++++++++++++++
 src/paimon/core/io/single_file_writer_test.cpp     | 109 ++++++++++
 15 files changed, 1996 insertions(+)

diff --git a/src/paimon/core/io/compact_increment.h 
b/src/paimon/core/io/compact_increment.h
new file mode 100644
index 0000000..8304d7a
--- /dev/null
+++ b/src/paimon/core/io/compact_increment.h
@@ -0,0 +1,156 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <iterator>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "fmt/format.h"
+#include "fmt/ranges.h"
+#include "paimon/common/utils/object_utils.h"
+#include "paimon/core/index/index_file_meta.h"
+#include "paimon/core/io/data_file_meta.h"
+
+namespace paimon {
+// Files changed before and after compaction, with changelog produced during 
compaction.
+class CompactIncrement {
+ public:
+    CompactIncrement(std::vector<std::shared_ptr<DataFileMeta>>&& 
compact_before,
+                     std::vector<std::shared_ptr<DataFileMeta>>&& 
compact_after,
+                     std::vector<std::shared_ptr<DataFileMeta>>&& 
changelog_files)
+        : CompactIncrement(std::move(compact_before), std::move(compact_after),
+                           std::move(changelog_files), {}, {}) {}
+
+    CompactIncrement(std::vector<std::shared_ptr<DataFileMeta>>&& 
compact_before,
+                     std::vector<std::shared_ptr<DataFileMeta>>&& 
compact_after,
+                     std::vector<std::shared_ptr<DataFileMeta>>&& 
changelog_files,
+                     std::vector<std::shared_ptr<IndexFileMeta>>&& 
new_index_files,
+                     std::vector<std::shared_ptr<IndexFileMeta>>&& 
deleted_index_files)
+        : compact_before_(std::move(compact_before)),
+          compact_after_(std::move(compact_after)),
+          changelog_files_(std::move(changelog_files)),
+          new_index_files_(std::move(new_index_files)),
+          deleted_index_files_(std::move(deleted_index_files)) {}
+
+    const std::vector<std::shared_ptr<DataFileMeta>>& CompactBefore() const {
+        return compact_before_;
+    }
+
+    const std::vector<std::shared_ptr<DataFileMeta>>& CompactAfter() const {
+        return compact_after_;
+    }
+
+    const std::vector<std::shared_ptr<DataFileMeta>>& ChangelogFiles() const {
+        return changelog_files_;
+    }
+
+    const std::vector<std::shared_ptr<IndexFileMeta>>& NewIndexFiles() const {
+        return new_index_files_;
+    }
+
+    const std::vector<std::shared_ptr<IndexFileMeta>>& DeletedIndexFiles() 
const {
+        return deleted_index_files_;
+    }
+
+    void AddNewIndexFiles(std::vector<std::shared_ptr<IndexFileMeta>>&& 
new_index_files) {
+        new_index_files_.insert(new_index_files_.end(),
+                                
std::make_move_iterator(new_index_files.begin()),
+                                
std::make_move_iterator(new_index_files.end()));
+    }
+
+    void AddDeletedIndexFiles(std::vector<std::shared_ptr<IndexFileMeta>>&& 
deleted_index_files) {
+        deleted_index_files_.insert(deleted_index_files_.end(),
+                                    
std::make_move_iterator(deleted_index_files.begin()),
+                                    
std::make_move_iterator(deleted_index_files.end()));
+    }
+
+    bool IsEmpty() const {
+        return compact_before_.empty() && compact_after_.empty() && 
changelog_files_.empty() &&
+               new_index_files_.empty() && deleted_index_files_.empty();
+    }
+
+    bool operator==(const CompactIncrement& other) const {
+        if (this == &other) {
+            return true;
+        }
+        return ObjectUtils::Equal(compact_before_, other.compact_before_) &&
+               ObjectUtils::Equal(compact_after_, other.compact_after_) &&
+               ObjectUtils::Equal(changelog_files_, other.changelog_files_) &&
+               ObjectUtils::Equal(new_index_files_, other.new_index_files_) &&
+               ObjectUtils::Equal(deleted_index_files_, 
other.deleted_index_files_);
+    }
+
+    bool TEST_Equal(const CompactIncrement& other) const {
+        if (this == &other) {
+            return true;
+        }
+        return ObjectUtils::TEST_Equal(compact_before_, other.compact_before_) 
&&
+               ObjectUtils::TEST_Equal(compact_after_, other.compact_after_) &&
+               ObjectUtils::TEST_Equal(changelog_files_, 
other.changelog_files_) &&
+               ObjectUtils::TEST_Equal(new_index_files_, 
other.new_index_files_) &&
+               ObjectUtils::TEST_Equal(deleted_index_files_, 
other.deleted_index_files_);
+    }
+
+    std::string ToString() const {
+        std::vector<std::string> compact_before_names;
+        compact_before_names.reserve(compact_before_.size());
+        for (const auto& file : compact_before_) {
+            compact_before_names.emplace_back(file->file_name);
+        }
+        std::vector<std::string> compact_after_names;
+        compact_after_names.reserve(compact_after_.size());
+        for (const auto& file : compact_after_) {
+            compact_after_names.emplace_back(file->file_name);
+        }
+        std::vector<std::string> changelog_files_names;
+        changelog_files_names.reserve(changelog_files_.size());
+        for (const auto& file : changelog_files_) {
+            changelog_files_names.emplace_back(file->file_name);
+        }
+        std::vector<std::string> new_index_names;
+        new_index_names.reserve(new_index_files_.size());
+        for (const auto& new_index_file : new_index_files_) {
+            new_index_names.emplace_back(new_index_file->FileName());
+        }
+        std::vector<std::string> deleted_index_names;
+        deleted_index_names.reserve(deleted_index_files_.size());
+        for (const auto& deleted_index_file : deleted_index_files_) {
+            deleted_index_names.emplace_back(deleted_index_file->FileName());
+        }
+
+        return fmt::format(
+            "CompactIncrement {{compactBefore = {}, compactAfter = {}, 
changelogFiles = {}, "
+            "newIndexFiles = {}, deletedIndexFiles = {}}}",
+            fmt::join(compact_before_names, ", "), 
fmt::join(compact_after_names, ", "),
+            fmt::join(changelog_files_names, ", "), fmt::join(new_index_names, 
", "),
+            fmt::join(deleted_index_names, ", "));
+    }
+
+ private:
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before_;
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after_;
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files_;
+    std::vector<std::shared_ptr<IndexFileMeta>> new_index_files_;
+    std::vector<std::shared_ptr<IndexFileMeta>> deleted_index_files_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/compact_increment_test.cpp 
b/src/paimon/core/io/compact_increment_test.cpp
new file mode 100644
index 0000000..8deae09
--- /dev/null
+++ b/src/paimon/core/io/compact_increment_test.cpp
@@ -0,0 +1,132 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/compact_increment.h"
+
+#include <optional>
+#include <vector>
+
+#include "gtest/gtest.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/manifest/file_source.h"
+#include "paimon/core/stats/simple_stats.h"
+#include "paimon/result.h"
+
+namespace paimon::test {
+
+class CompactIncrementTest : public ::testing::Test {
+ public:
+    std::shared_ptr<DataFileMeta> CreateDataFileMeta(const std::string& 
file_name) {
+        return DataFileMeta::ForAppend(file_name, 100, 100, 
SimpleStats::EmptyStats(), 0, 100, 0,
+                                       FileSource::Append(), std::nullopt, 
std::nullopt,
+                                       std::nullopt, std::nullopt)
+            .value();
+    }
+};
+
+TEST_F(CompactIncrementTest, TestCompactBefore) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before = {file1, file2};
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after = {file3};
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files = {file4};
+
+    CompactIncrement increment(std::move(compact_before), 
std::move(compact_after),
+                               std::move(changelog_files));
+
+    ASSERT_TRUE(ObjectUtils::Equal(increment.CompactBefore(), {file1, file2}));
+}
+
+TEST_F(CompactIncrementTest, TestCompactAfter) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before = {file1};
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after = {file2, file3};
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files = {file4};
+
+    CompactIncrement increment(std::move(compact_before), 
std::move(compact_after),
+                               std::move(changelog_files));
+    ASSERT_TRUE(ObjectUtils::Equal(increment.CompactAfter(), {file2, file3}));
+}
+
+TEST_F(CompactIncrementTest, TestChangelogFiles) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before = {file1};
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after = {file2};
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files = {file3, 
file4};
+
+    CompactIncrement increment(std::move(compact_before), 
std::move(compact_after),
+                               std::move(changelog_files));
+    ASSERT_TRUE(ObjectUtils::Equal(increment.ChangelogFiles(), {file3, 
file4}));
+}
+
+TEST_F(CompactIncrementTest, TestIsEmpty) {
+    CompactIncrement increment({}, {}, {});
+    ASSERT_TRUE(increment.IsEmpty());
+}
+
+TEST_F(CompactIncrementTest, TestEqualityOperator) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before1 = {file1};
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after1 = {file2};
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files1 = {file3};
+
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before2 = {file1};
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after2 = {file2};
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files2 = {file3};
+
+    CompactIncrement increment1(std::move(compact_before1), 
std::move(compact_after1),
+                                std::move(changelog_files1));
+    CompactIncrement increment2(std::move(compact_before2), 
std::move(compact_after2),
+                                std::move(changelog_files2));
+    ASSERT_EQ(increment1, increment2);
+}
+
+TEST_F(CompactIncrementTest, TestToString) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    std::vector<std::shared_ptr<DataFileMeta>> compact_before = {file1, file2};
+    std::vector<std::shared_ptr<DataFileMeta>> compact_after = {file3};
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files = {file4};
+
+    CompactIncrement increment(std::move(compact_before), 
std::move(compact_after),
+                               std::move(changelog_files));
+    std::string expected =
+        "CompactIncrement {compactBefore = file1, file2, compactAfter = file3, 
changelogFiles = "
+        "file4, newIndexFiles = , deletedIndexFiles = }";
+    ASSERT_EQ(increment.ToString(), expected);
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/core/io/data_file_writer.cpp 
b/src/paimon/core/io/data_file_writer.cpp
new file mode 100644
index 0000000..6f8bb3b
--- /dev/null
+++ b/src/paimon/core/io/data_file_writer.cpp
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/data_file_writer.h"
+
+#include <cassert>
+
+#include "arrow/c/abi.h"
+#include "paimon/common/utils/long_counter.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/core/stats/simple_stats.h"
+#include "paimon/core/stats/simple_stats_converter.h"
+#include "paimon/format/format_stats_extractor.h"
+
+namespace paimon {
+class MemoryPool;
+
+DataFileWriter::DataFileWriter(
+    const std::string& compression, std::function<Status(::ArrowArray*, 
::ArrowArray*)> converter,
+    int64_t schema_id, const std::shared_ptr<LongCounter>& seq_num_counter, 
FileSource file_source,
+    const std::shared_ptr<FormatStatsExtractor>& stats_extractor, bool 
is_external_path,
+    const std::optional<std::vector<std::string>>& write_cols,
+    const std::shared_ptr<MemoryPool>& pool)
+    : SingleFileWriter(compression, converter),
+      pool_(pool),
+      schema_id_(schema_id),
+      is_external_path_(is_external_path),
+      seq_num_counter_(seq_num_counter),
+      file_source_(file_source),
+      stats_extractor_(stats_extractor),
+      write_cols_(write_cols) {}
+
+Status DataFileWriter::Write(ArrowArray* batch) {
+    int64_t record_count = batch->length;
+    PAIMON_RETURN_NOT_OK(SingleFileWriter::Write(batch));
+    seq_num_counter_->Add(record_count);
+    return Status::OK();
+}
+
+Result<std::shared_ptr<DataFileMeta>> DataFileWriter::GetResult() {
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<ColumnStats>> 
field_stats, GetFieldStats());
+    PAIMON_ASSIGN_OR_RAISE(SimpleStats stats,
+                           SimpleStatsConverter::ToBinary(field_stats, 
pool_.get()));
+    // TODO(xinyu.lxy): do not support write value stats cols for now
+    std::optional<std::string> final_path;
+    if (is_external_path_) {
+        PAIMON_ASSIGN_OR_RAISE(Path external_path, PathUtil::ToPath(path_));
+        final_path = external_path.ToString();
+    }
+    return DataFileMeta::ForAppend(
+        PathUtil::GetName(path_), output_bytes_, RecordCount(), stats,
+        seq_num_counter_->GetValue() - RecordCount(), 
seq_num_counter_->GetValue() - 1, schema_id_,
+        {}, /*embedded_index=*/nullptr, file_source_, 
/*value_stats_cols=*/std::nullopt, final_path,
+        /*first_row_id=*/std::nullopt, write_cols_);
+}
+
+Result<std::vector<std::shared_ptr<ColumnStats>>> 
DataFileWriter::GetFieldStats() {
+    if (!closed_) {
+        return Status::Invalid("Cannot access metric unless the writer is 
closed.");
+    }
+    if (stats_extractor_ == nullptr) {
+        assert(false);
+        return Status::Invalid("simple stats extractor is null pointer.");
+    }
+    return stats_extractor_->Extract(fs_, path_, pool_);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/data_file_writer.h 
b/src/paimon/core/io/data_file_writer.h
new file mode 100644
index 0000000..097c9b9
--- /dev/null
+++ b/src/paimon/core/io/data_file_writer.h
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "arrow/c/abi.h"
+#include "paimon/common/utils/long_counter.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/single_file_writer.h"
+#include "paimon/core/manifest/file_source.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+
+namespace paimon {
+
+class ColumnStats;
+class FormatStatsExtractor;
+class LongCounter;
+class MemoryPool;
+
+class DataFileWriter : public SingleFileWriter<::ArrowArray*, 
std::shared_ptr<DataFileMeta>> {
+ public:
+    DataFileWriter(const std::string& compression,
+                   std::function<Status(::ArrowArray*, ::ArrowArray*)> 
converter, int64_t schema_id,
+                   const std::shared_ptr<LongCounter>& seq_num_counter, 
FileSource file_source,
+                   const std::shared_ptr<FormatStatsExtractor>& 
stats_extractor,
+                   bool is_external_path, const 
std::optional<std::vector<std::string>>& write_cols,
+                   const std::shared_ptr<MemoryPool>& pool);
+
+    Status Write(::ArrowArray* batch) override;
+
+    Result<std::shared_ptr<DataFileMeta>> GetResult() override;
+
+ private:
+    Result<std::vector<std::shared_ptr<ColumnStats>>> GetFieldStats();
+
+ private:
+    std::shared_ptr<MemoryPool> pool_;
+    int64_t schema_id_;
+    bool is_external_path_;
+
+    std::shared_ptr<LongCounter> seq_num_counter_;
+    FileSource file_source_;
+    std::shared_ptr<FormatStatsExtractor> stats_extractor_;
+    std::optional<std::vector<std::string>> write_cols_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/data_increment.h 
b/src/paimon/core/io/data_increment.h
new file mode 100644
index 0000000..602eb37
--- /dev/null
+++ b/src/paimon/core/io/data_increment.h
@@ -0,0 +1,158 @@
+/*
+ * 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.
+ */
+
+#pragma once
+#include <iterator>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "fmt/format.h"
+#include "fmt/ranges.h"
+#include "paimon/common/utils/object_utils.h"
+#include "paimon/core/index/index_file_meta.h"
+#include "paimon/core/io/data_file_meta.h"
+
+namespace paimon {
+// Increment of data files, changelog files and index files.
+class DataIncrement {
+ public:
+    explicit DataIncrement(std::vector<std::shared_ptr<IndexFileMeta>>&& 
new_index_files)
+        : DataIncrement({}, {}, {}, std::move(new_index_files), {}) {}
+
+    DataIncrement(std::vector<std::shared_ptr<DataFileMeta>>&& new_files,
+                  std::vector<std::shared_ptr<DataFileMeta>>&& deleted_files,
+                  std::vector<std::shared_ptr<DataFileMeta>>&& changelog_files)
+        : DataIncrement(std::move(new_files), std::move(deleted_files), 
std::move(changelog_files),
+                        {}, {}) {}
+
+    DataIncrement(std::vector<std::shared_ptr<DataFileMeta>>&& new_files,
+                  std::vector<std::shared_ptr<DataFileMeta>>&& deleted_files,
+                  std::vector<std::shared_ptr<DataFileMeta>>&& changelog_files,
+                  std::vector<std::shared_ptr<IndexFileMeta>>&& 
new_index_files,
+                  std::vector<std::shared_ptr<IndexFileMeta>>&& 
deleted_index_files)
+        : new_files_(std::move(new_files)),
+          deleted_files_(std::move(deleted_files)),
+          changelog_files_(std::move(changelog_files)),
+          new_index_files_(std::move(new_index_files)),
+          deleted_index_files_(std::move(deleted_index_files)) {}
+
+    const std::vector<std::shared_ptr<DataFileMeta>>& NewFiles() const {
+        return new_files_;
+    }
+
+    const std::vector<std::shared_ptr<DataFileMeta>>& DeletedFiles() const {
+        return deleted_files_;
+    }
+
+    const std::vector<std::shared_ptr<DataFileMeta>>& ChangelogFiles() const {
+        return changelog_files_;
+    }
+
+    const std::vector<std::shared_ptr<IndexFileMeta>>& NewIndexFiles() const {
+        return new_index_files_;
+    }
+
+    const std::vector<std::shared_ptr<IndexFileMeta>>& DeletedIndexFiles() 
const {
+        return deleted_index_files_;
+    }
+
+    void AddNewIndexFiles(std::vector<std::shared_ptr<IndexFileMeta>>&& 
new_index_files) {
+        new_index_files_.insert(new_index_files_.end(),
+                                
std::make_move_iterator(new_index_files.begin()),
+                                
std::make_move_iterator(new_index_files.end()));
+    }
+
+    void AddDeletedIndexFiles(std::vector<std::shared_ptr<IndexFileMeta>>&& 
deleted_index_files) {
+        deleted_index_files_.insert(deleted_index_files_.end(),
+                                    
std::make_move_iterator(deleted_index_files.begin()),
+                                    
std::make_move_iterator(deleted_index_files.end()));
+    }
+
+    bool IsEmpty() const {
+        return new_files_.empty() && deleted_files_.empty() && 
changelog_files_.empty() &&
+               new_index_files_.empty() && deleted_index_files_.empty();
+    }
+
+    bool operator==(const DataIncrement& other) const {
+        if (this == &other) {
+            return true;
+        }
+        return ObjectUtils::Equal(new_files_, other.new_files_) &&
+               ObjectUtils::Equal(deleted_files_, other.deleted_files_) &&
+               ObjectUtils::Equal(changelog_files_, other.changelog_files_) &&
+               ObjectUtils::Equal(new_index_files_, other.new_index_files_) &&
+               ObjectUtils::Equal(deleted_index_files_, 
other.deleted_index_files_);
+    }
+
+    bool TEST_Equal(const DataIncrement& other) const {
+        if (this == &other) {
+            return true;
+        }
+        return ObjectUtils::TEST_Equal(new_files_, other.new_files_) &&
+               ObjectUtils::TEST_Equal(deleted_files_, other.deleted_files_) &&
+               ObjectUtils::TEST_Equal(changelog_files_, 
other.changelog_files_) &&
+               ObjectUtils::TEST_Equal(new_index_files_, 
other.new_index_files_) &&
+               ObjectUtils::TEST_Equal(deleted_index_files_, 
other.deleted_index_files_);
+    }
+
+    std::string ToString() const {
+        std::vector<std::string> new_files_names;
+        new_files_names.reserve(new_files_.size());
+        for (const auto& new_file : new_files_) {
+            new_files_names.emplace_back(new_file->file_name);
+        }
+        std::vector<std::string> deleted_files_names;
+        deleted_files_names.reserve(deleted_files_.size());
+        for (const auto& deleted_file : deleted_files_) {
+            deleted_files_names.emplace_back(deleted_file->file_name);
+        }
+        std::vector<std::string> changelog_files_names;
+        changelog_files_names.reserve(changelog_files_.size());
+        for (const auto& changelog_file : changelog_files_) {
+            changelog_files_names.emplace_back(changelog_file->file_name);
+        }
+        std::vector<std::string> new_index_names;
+        new_index_names.reserve(new_index_files_.size());
+        for (const auto& new_index_file : new_index_files_) {
+            new_index_names.emplace_back(new_index_file->FileName());
+        }
+        std::vector<std::string> deleted_index_names;
+        deleted_index_names.reserve(deleted_index_files_.size());
+        for (const auto& deleted_index_file : deleted_index_files_) {
+            deleted_index_names.emplace_back(deleted_index_file->FileName());
+        }
+
+        return fmt::format(
+            "DataIncrement {{newFiles = {}, deletedFiles = {}, changelogFiles 
= {}, newIndexFiles "
+            "= {}, deletedIndexFiles = {}}}",
+            fmt::join(new_files_names, ", "), fmt::join(deleted_files_names, 
", "),
+            fmt::join(changelog_files_names, ", "), fmt::join(new_index_names, 
", "),
+            fmt::join(deleted_index_names, ", "));
+    }
+
+ private:
+    std::vector<std::shared_ptr<DataFileMeta>> new_files_;
+    std::vector<std::shared_ptr<DataFileMeta>> deleted_files_;
+    std::vector<std::shared_ptr<DataFileMeta>> changelog_files_;
+    std::vector<std::shared_ptr<IndexFileMeta>> new_index_files_;
+    std::vector<std::shared_ptr<IndexFileMeta>> deleted_index_files_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/data_increment_test.cpp 
b/src/paimon/core/io/data_increment_test.cpp
new file mode 100644
index 0000000..21c8ea8
--- /dev/null
+++ b/src/paimon/core/io/data_increment_test.cpp
@@ -0,0 +1,107 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/data_increment.h"
+
+#include <optional>
+
+#include "gtest/gtest.h"
+#include "paimon/core/manifest/file_source.h"
+#include "paimon/core/stats/simple_stats.h"
+#include "paimon/result.h"
+
+namespace paimon::test {
+
+class DataIncrementTest : public ::testing::Test {
+ public:
+    std::shared_ptr<DataFileMeta> CreateDataFileMeta(const std::string& 
file_name) {
+        return DataFileMeta::ForAppend(file_name, 100, 100, 
SimpleStats::EmptyStats(), 0, 100, 0,
+                                       FileSource::Append(), std::nullopt, 
std::nullopt,
+                                       std::nullopt, std::nullopt)
+            .value();
+    }
+};
+
+TEST_F(DataIncrementTest, TestNewFiles) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+
+    DataIncrement increment({file1, file2}, {}, {});
+    ASSERT_EQ(increment.NewFiles().size(), 2);
+    ASSERT_EQ(increment.NewFiles()[0]->file_name, "file1");
+    ASSERT_EQ(increment.NewFiles()[1]->file_name, "file2");
+}
+
+TEST_F(DataIncrementTest, TestDeletedFiles) {
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+
+    DataIncrement increment({}, {file3}, {});
+    ASSERT_EQ(increment.DeletedFiles().size(), 1);
+    ASSERT_EQ(increment.DeletedFiles()[0]->file_name, "file3");
+}
+
+TEST_F(DataIncrementTest, TestChangelogFiles) {
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    DataIncrement increment({}, {}, {file4});
+    ASSERT_EQ(increment.ChangelogFiles().size(), 1);
+    ASSERT_EQ(increment.ChangelogFiles()[0]->file_name, "file4");
+}
+
+TEST_F(DataIncrementTest, TestIsEmpty) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    DataIncrement increment1({}, {}, {});
+    ASSERT_TRUE(increment1.IsEmpty());
+
+    DataIncrement increment2({file1}, {}, {});
+    ASSERT_FALSE(increment2.IsEmpty());
+
+    DataIncrement increment3({}, {}, {file4});
+    ASSERT_FALSE(increment3.IsEmpty());
+}
+
+TEST_F(DataIncrementTest, TestEqualityOperator) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    DataIncrement increment1({file1}, {file3}, {file4});
+    DataIncrement increment2({file1}, {file3}, {file4});
+    DataIncrement increment3({file2}, {file3}, {file4});
+
+    ASSERT_TRUE(increment1 == increment2);
+    ASSERT_FALSE(increment1 == increment3);
+}
+
+TEST_F(DataIncrementTest, TestToString) {
+    std::shared_ptr<DataFileMeta> file1 = CreateDataFileMeta("file1");
+    std::shared_ptr<DataFileMeta> file2 = CreateDataFileMeta("file2");
+    std::shared_ptr<DataFileMeta> file3 = CreateDataFileMeta("file3");
+    std::shared_ptr<DataFileMeta> file4 = CreateDataFileMeta("file4");
+
+    DataIncrement increment({file1, file2}, {file3}, {file4});
+    std::string expected =
+        "DataIncrement {newFiles = file1, file2, deletedFiles = file3, 
changelogFiles = file4, "
+        "newIndexFiles = , deletedIndexFiles = }";
+    ASSERT_EQ(increment.ToString(), expected);
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/core/io/file_writer.h b/src/paimon/core/io/file_writer.h
new file mode 100644
index 0000000..b03fbd2
--- /dev/null
+++ b/src/paimon/core/io/file_writer.h
@@ -0,0 +1,63 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+
+#include "paimon/type_fwd.h"
+
+namespace paimon {
+
+/// File writer to accept one record or a branch of records and generate 
metadata after closing it.
+/// <T> record type.
+/// <R> file result to collect.
+template <typename T, typename R>
+class FileWriter {
+ public:
+    FileWriter() = default;
+    virtual ~FileWriter() = default;
+
+    /// Add one record to this file writer.
+    ///
+    /// @note If any error occurs during writing, the writer should clean up 
useless files for
+    /// the user.
+    ///
+    /// @param record to write.
+    /// @return Status if encounter any IO error.
+    virtual Status Write(T record) = 0;
+
+    /// The total written record count.
+    ///
+    /// @return record count.
+    virtual int64_t RecordCount() const = 0;
+
+    /// Abort to clear orphan file(s) if encounter any error.
+    ///
+    /// @note This implementation must be reentrant.
+    virtual void Abort() = 0;
+
+    virtual Status Close() = 0;
+
+    /// @return the result for this closed file writer.
+    virtual Result<R> GetResult() = 0;
+
+    virtual std::shared_ptr<Metrics> GetMetrics() const = 0;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/key_value_data_file_writer.cpp 
b/src/paimon/core/io/key_value_data_file_writer.cpp
new file mode 100644
index 0000000..82bbba9
--- /dev/null
+++ b/src/paimon/core/io/key_value_data_file_writer.cpp
@@ -0,0 +1,203 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/key_value_data_file_writer.h"
+
+#include <algorithm>
+#include <cassert>
+#include <cstddef>
+#include <optional>
+#include <utility>
+#include <variant>
+
+#include "arrow/type.h"
+#include "fmt/format.h"
+#include "paimon/common/data/binary_array.h"
+#include "paimon/common/data/binary_array_writer.h"
+#include "paimon/common/data/binary_row.h"
+#include "paimon/common/data/binary_row_writer.h"
+#include "paimon/common/data/data_define.h"
+#include "paimon/common/data/internal_row.h"
+#include "paimon/common/table/special_fields.h"
+#include "paimon/common/utils/date_time_utils.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/core/stats/simple_stats.h"
+#include "paimon/core/stats/simple_stats_converter.h"
+#include "paimon/data/timestamp.h"
+#include "paimon/format/format_stats_extractor.h"
+
+struct ArrowArray;
+
+namespace paimon {
+class MemoryPool;
+
+KeyValueDataFileWriter::KeyValueDataFileWriter(
+    const std::string& compression, std::function<Status(KeyValueBatch&&, 
::ArrowArray*)> converter,
+    int64_t schema_id, int32_t level, FileSource file_source,
+    const std::vector<std::string>& primary_keys,
+    const std::shared_ptr<FormatStatsExtractor>& stats_extractor,
+    const std::shared_ptr<arrow::Schema>& write_schema, bool is_external_path,
+    const std::shared_ptr<MemoryPool>& pool)
+    : SingleFileWriter(compression, converter),
+      pool_(pool),
+      schema_id_(schema_id),
+      level_(level),
+      file_source_(file_source),
+      primary_keys_(primary_keys),
+      stats_extractor_(stats_extractor),
+      write_schema_(write_schema),
+      is_external_path_(is_external_path),
+      disable_stats_(stats_extractor == nullptr) {}
+
+Status KeyValueDataFileWriter::Write(KeyValueBatch batch) {
+    // update min and max key
+    if (!min_key_) {
+        min_key_ = batch.min_key;
+    }
+    max_key_ = batch.max_key;
+    // update min/max sequence number
+    min_sequence_number_ = std::min(min_sequence_number_, 
batch.min_sequence_number);
+    max_sequence_number_ = std::max(max_sequence_number_, 
batch.max_sequence_number);
+    // update delete row count
+    delete_row_count_ += batch.delete_row_count;
+    PAIMON_RETURN_NOT_OK(SingleFileWriter::Write(std::move(batch)));
+    return Status::OK();
+}
+
+Result<std::shared_ptr<DataFileMeta>> KeyValueDataFileWriter::GetResult() {
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<ColumnStats>> 
field_stats, GetFieldStats());
+    if (!disable_stats_ && field_stats.size() != 
static_cast<size_t>(write_schema_->num_fields())) {
+        return Status::Invalid("invalid field stats, mismatch with write 
schema");
+    }
+    // min/max key
+    BinaryRow min_key(primary_keys_.size());
+    BinaryRow max_key(primary_keys_.size());
+    PAIMON_RETURN_NOT_OK(GenerateMinMaxKey(&min_key, &max_key));
+
+    // key value stats
+    SimpleStats key_stats = SimpleStats::EmptyStats();
+    SimpleStats value_stats = SimpleStats::EmptyStats();
+    if (!disable_stats_) {
+        PAIMON_RETURN_NOT_OK(GenerateKeyValueStats(field_stats, &key_stats, 
&value_stats));
+    } else {
+        PAIMON_RETURN_NOT_OK(GenerateKeyStatsWithAllNull(&key_stats));
+    }
+    // TODO(xinyu.lxy): do not support write value stats cols for now
+    std::optional<std::string> final_path;
+    if (is_external_path_) {
+        PAIMON_ASSIGN_OR_RAISE(Path external_path, PathUtil::ToPath(path_));
+        final_path = external_path.ToString();
+    }
+    PAIMON_ASSIGN_OR_RAISE(int64_t local_micro, 
DateTimeUtils::GetCurrentLocalTimeUs());
+    return std::make_shared<DataFileMeta>(
+        PathUtil::GetName(path_), output_bytes_, RecordCount(), min_key, 
max_key, key_stats,
+        value_stats, min_sequence_number_, max_sequence_number_, schema_id_, 
level_,
+        /*extra_files=*/std::vector<std::optional<std::string>>(),
+        Timestamp(/*millisecond=*/local_micro / 1000, 
/*nano_of_millisecond=*/0), delete_row_count_,
+        /*embedded_index=*/nullptr, file_source_,
+        /*value_stats_cols=*/std::nullopt, final_path, 
/*first_row_id=*/std::nullopt,
+        /*write_cols=*/std::nullopt);
+}
+
+Status KeyValueDataFileWriter::GenerateMinMaxKey(BinaryRow* min_key, 
BinaryRow* max_key) const {
+    BinaryRowWriter min_writer(min_key, /*initial_size=*/1024, pool_.get());
+    BinaryRowWriter max_writer(max_key, /*initial_size=*/1024, pool_.get());
+    min_writer.Reset();
+    max_writer.Reset();
+    for (size_t i = 0; i < primary_keys_.size(); ++i) {
+        auto data_type = 
write_schema_->GetFieldByName(primary_keys_[i])->type();
+        InternalRow::FieldGetterFunc getter;
+        PAIMON_ASSIGN_OR_RAISE(getter,
+                               InternalRow::CreateFieldGetter(i, data_type, 
/*use_view=*/true));
+        BinaryRowWriter::FieldSetterFunc setter;
+        PAIMON_ASSIGN_OR_RAISE(setter, BinaryRowWriter::CreateFieldSetter(i, 
data_type));
+        setter(getter(*min_key_), &min_writer);
+        setter(getter(*max_key_), &max_writer);
+    }
+    min_writer.Complete();
+    max_writer.Complete();
+    return Status::OK();
+}
+
+Status KeyValueDataFileWriter::GenerateKeyValueStats(
+    const std::vector<std::shared_ptr<ColumnStats>>& field_stats, SimpleStats* 
key_stats,
+    SimpleStats* value_stats) const {
+    // key stats
+    std::vector<std::shared_ptr<ColumnStats>> key_column_stats;
+    key_column_stats.reserve(primary_keys_.size());
+    for (const auto& key : primary_keys_) {
+        int32_t idx = write_schema_->GetFieldIndex(key);
+        if (idx == -1) {
+            return Status::Invalid(
+                fmt::format("cannot find primary key field {} in write 
schema", key));
+        }
+        key_column_stats.push_back(field_stats[idx]);
+    }
+    PAIMON_ASSIGN_OR_RAISE(*key_stats,
+                           SimpleStatsConverter::ToBinary(key_column_stats, 
pool_.get()));
+    // value stats
+    std::vector<std::shared_ptr<ColumnStats>> value_column_stats(
+        field_stats.begin() + SpecialFields::KEY_VALUE_SPECIAL_FIELD_COUNT, 
field_stats.end());
+    PAIMON_ASSIGN_OR_RAISE(*value_stats,
+                           SimpleStatsConverter::ToBinary(value_column_stats, 
pool_.get()));
+    return Status::OK();
+}
+
+Status KeyValueDataFileWriter::GenerateKeyStatsWithAllNull(SimpleStats* 
key_stats) const {
+    BinaryRow min_values(primary_keys_.size());
+    BinaryRow max_values(primary_keys_.size());
+    BinaryArray null_counts;
+
+    BinaryRowWriter min_writer(&min_values, /*initial_size=*/0, pool_.get());
+    BinaryRowWriter max_writer(&max_values, /*initial_size=*/0, pool_.get());
+    BinaryArrayWriter null_counts_writer(&null_counts, primary_keys_.size(), 
sizeof(int64_t),
+                                         pool_.get());
+    min_writer.Reset();
+    max_writer.Reset();
+    null_counts_writer.Reset();
+
+    for (size_t i = 0; i < primary_keys_.size(); ++i) {
+        auto data_type = 
write_schema_->GetFieldByName(primary_keys_[i])->type();
+        BinaryRowWriter::FieldSetterFunc setter;
+        PAIMON_ASSIGN_OR_RAISE(setter, BinaryRowWriter::CreateFieldSetter(i, 
data_type));
+        setter(NullType(), &min_writer);
+        setter(NullType(), &max_writer);
+        null_counts_writer.SetNullAt(i);
+    }
+    min_writer.Complete();
+    max_writer.Complete();
+    null_counts_writer.Complete();
+    *key_stats = SimpleStats(min_values, max_values, null_counts);
+    return Status::OK();
+}
+
+Result<std::vector<std::shared_ptr<ColumnStats>>> 
KeyValueDataFileWriter::GetFieldStats() {
+    if (!closed_) {
+        return Status::Invalid("Cannot access metric unless the writer is 
closed.");
+    }
+    if (disable_stats_) {
+        return std::vector<std::shared_ptr<ColumnStats>>();
+    }
+    if (stats_extractor_ == nullptr) {
+        assert(false);
+        return Status::Invalid("simple stats extractor is null pointer.");
+    }
+    return stats_extractor_->Extract(fs_, path_, pool_);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/key_value_data_file_writer.h 
b/src/paimon/core/io/key_value_data_file_writer.h
new file mode 100644
index 0000000..f62bf64
--- /dev/null
+++ b/src/paimon/core/io/key_value_data_file_writer.h
@@ -0,0 +1,89 @@
+/*
+ * 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.
+ */
+
+#pragma once
+#include <cstdint>
+#include <functional>
+#include <limits>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/single_file_writer.h"
+#include "paimon/core/key_value.h"
+#include "paimon/core/manifest/file_source.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+
+namespace arrow {
+class Schema;
+}  // namespace arrow
+struct ArrowArray;
+
+namespace paimon {
+class ColumnStats;
+class FormatStatsExtractor;
+class BinaryRow;
+class InternalRow;
+class MemoryPool;
+class SimpleStats;
+
+class KeyValueDataFileWriter
+    : public SingleFileWriter<KeyValueBatch, std::shared_ptr<DataFileMeta>> {
+ public:
+    KeyValueDataFileWriter(const std::string& compression,
+                           std::function<Status(KeyValueBatch&&, 
::ArrowArray*)> converter,
+                           int64_t schema_id, int32_t level, FileSource 
file_source,
+                           const std::vector<std::string>& primary_keys,
+                           const std::shared_ptr<FormatStatsExtractor>& 
stats_extractor,
+                           const std::shared_ptr<arrow::Schema>& write_schema,
+                           bool is_external_path, const 
std::shared_ptr<MemoryPool>& pool);
+
+    Status Write(KeyValueBatch batch) override;
+
+    Result<std::shared_ptr<DataFileMeta>> GetResult() override;
+
+ private:
+    Result<std::vector<std::shared_ptr<ColumnStats>>> GetFieldStats();
+
+    Status GenerateMinMaxKey(BinaryRow* min_key, BinaryRow* max_key) const;
+
+    Status GenerateKeyValueStats(const 
std::vector<std::shared_ptr<ColumnStats>>& field_stats,
+                                 SimpleStats* key_stats, SimpleStats* 
value_stats) const;
+    Status GenerateKeyStatsWithAllNull(SimpleStats* key_stats) const;
+
+ private:
+    std::shared_ptr<MemoryPool> pool_;
+    int64_t schema_id_;
+    int32_t level_;
+    FileSource file_source_;
+    std::vector<std::string> primary_keys_;
+    std::shared_ptr<FormatStatsExtractor> stats_extractor_;
+    std::shared_ptr<arrow::Schema> write_schema_;
+    bool is_external_path_;
+    bool disable_stats_;
+
+    int64_t delete_row_count_ = 0;
+    int64_t min_sequence_number_ = std::numeric_limits<int64_t>::max();
+    int64_t max_sequence_number_ = std::numeric_limits<int64_t>::min();
+    std::shared_ptr<InternalRow> min_key_;
+    std::shared_ptr<InternalRow> max_key_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/rolling_blob_file_writer.cpp 
b/src/paimon/core/io/rolling_blob_file_writer.cpp
new file mode 100644
index 0000000..ccd6ca4
--- /dev/null
+++ b/src/paimon/core/io/rolling_blob_file_writer.cpp
@@ -0,0 +1,198 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/rolling_blob_file_writer.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/array/array_base.h"
+#include "arrow/array/array_nested.h"
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "fmt/format.h"
+#include "fmt/ranges.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/macros.h"
+
+namespace arrow {
+class DataType;
+}  // namespace arrow
+
+namespace paimon {
+
+RollingBlobFileWriter::RollingBlobFileWriter(
+    int64_t target_file_size,
+    std::function<Result<std::unique_ptr<MainWriter>>()> create_file_writer,
+    const std::shared_ptr<arrow::Schema>& blob_schema,
+    MultipleBlobFileWriter::BlobWriterCreator blob_writer_creator,
+    const std::shared_ptr<arrow::DataType>& data_type)
+    : RollingFileWriter<::ArrowArray*, 
std::shared_ptr<DataFileMeta>>(target_file_size,
+                                                                      
create_file_writer),
+      blob_schema_(blob_schema),
+      blob_writer_creator_(std::move(blob_writer_creator)),
+      data_type_(data_type),
+      logger_(Logger::GetLogger("RollingBlobFileWriter")) {}
+
+Status RollingBlobFileWriter::Write(::ArrowArray* record) {
+    ScopeGuard guard([this]() -> void { this->Abort(); });
+    // Open the current writer if write the first record or roll over happen 
before.
+    if (PAIMON_UNLIKELY(current_writer_ == nullptr)) {
+        PAIMON_RETURN_NOT_OK(OpenCurrentWriter());
+    }
+    if (PAIMON_UNLIKELY(blob_writer_ == nullptr)) {
+        blob_writer_ = std::make_unique<MultipleBlobFileWriter>(blob_schema_, 
blob_writer_creator_);
+    }
+    int64_t record_count = record->length;
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> 
arrow_array,
+                                      arrow::ImportArray(record, data_type_));
+    auto struct_array = 
std::dynamic_pointer_cast<arrow::StructArray>(arrow_array);
+
+    PAIMON_ASSIGN_OR_RAISE(BlobUtils::SeparatedStructArrays separated_arrays,
+                           BlobUtils::SeparateBlobArray(struct_array));
+    // Write main (non-blob) data
+    ::ArrowArray c_main_array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(
+        arrow::ExportArray(*separated_arrays.main_array, &c_main_array));
+    ScopeGuard array_lifecycle_guard(
+        [&c_main_array]() -> void { ArrowArrayRelease(&c_main_array); });
+    PAIMON_RETURN_NOT_OK(current_writer_->Write(&c_main_array));
+
+    // Write blob data via MultipleBlobFileWriter (each blob field 
independently)
+    ::ArrowArray c_blob_array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(
+        arrow::ExportArray(*separated_arrays.blob_array, &c_blob_array));
+    ScopeGuard blob_array_guard([&c_blob_array]() -> void { 
ArrowArrayRelease(&c_blob_array); });
+    PAIMON_RETURN_NOT_OK(blob_writer_->Write(&c_blob_array));
+
+    record_count_ += record_count;
+    PAIMON_ASSIGN_OR_RAISE(bool need_rolling_file, NeedRollingFile());
+    if (need_rolling_file) {
+        PAIMON_RETURN_NOT_OK(CloseCurrentWriter());
+    }
+    guard.Release();
+    return Status::OK();
+}
+
+Status RollingBlobFileWriter::CloseCurrentWriter() {
+    if (current_writer_ == nullptr) {
+        return Status::OK();
+    }
+    if (blob_writer_ == nullptr) {
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DataFileMeta> main_data_file_meta, 
CloseMainWriter());
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<DataFileMeta>> 
blob_metas,
+                           CloseBlobWriter());
+    PAIMON_RETURN_NOT_OK(
+        ValidateFileConsistency(main_data_file_meta, blob_metas, 
blob_schema_->num_fields()));
+
+    results_.push_back(main_data_file_meta);
+    results_.insert(results_.end(), blob_metas.begin(), blob_metas.end());
+
+    current_writer_.reset();
+    return Status::OK();
+}
+
+Result<std::vector<std::shared_ptr<DataFileMeta>>> 
RollingBlobFileWriter::GetResult() {
+    if (!closed_) {
+        return Status::Invalid("Cannot access the results unless close all 
writers.");
+    }
+    return results_;
+}
+
+Result<std::shared_ptr<DataFileMeta>> RollingBlobFileWriter::CloseMainWriter() 
{
+    PAIMON_RETURN_NOT_OK(current_writer_->Close());
+    PAIMON_ASSIGN_OR_RAISE(auto abort_executor, 
current_writer_->GetAbortExecutor());
+    closed_writers_.push_back(abort_executor);
+    return current_writer_->GetResult();
+}
+
+Result<std::vector<std::shared_ptr<DataFileMeta>>> 
RollingBlobFileWriter::CloseBlobWriter() {
+    PAIMON_RETURN_NOT_OK(blob_writer_->Close());
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<DataFileMeta>> results,
+                           blob_writer_->GetResult());
+    blob_writer_.reset();
+    return results;
+}
+
+Status RollingBlobFileWriter::ValidateFileConsistency(
+    const std::shared_ptr<DataFileMeta>& main_data_file_meta,
+    const std::vector<std::shared_ptr<DataFileMeta>>& blob_tagged_metas, 
int32_t blob_field_count) {
+    if (blob_tagged_metas.empty()) {
+        return Status::OK();
+    }
+    // With multiple blob fields, each blob field produces its own set of 
files.
+    // total_blob_row_count should be exactly main_row_count * 
blob_field_count.
+    int64_t main_row_count = main_data_file_meta->row_count;
+    int64_t expected_blob_row_count = main_row_count * blob_field_count;
+    int64_t total_blob_row_count = 0;
+    for (const auto& blob_tagged_meta : blob_tagged_metas) {
+        total_blob_row_count += blob_tagged_meta->row_count;
+    }
+    if (total_blob_row_count != expected_blob_row_count) {
+        std::vector<std::string> blob_file_names;
+        for (const auto& blob_tagged_meta : blob_tagged_metas) {
+            blob_file_names.push_back(blob_tagged_meta->file_name);
+        }
+        return Status::Invalid(fmt::format(
+            "This is a bug: The row count of main file and blob files does not 
match. "
+            "Main file: {} (row count: {}), blob field count: {}, "
+            "expected blob row count: {}, blob files: {} (actual total row 
count: {})",
+            main_data_file_meta->file_name, main_row_count, blob_field_count,
+            expected_blob_row_count, fmt::join(blob_file_names, ", "), 
total_blob_row_count));
+    }
+    return Status::OK();
+}
+
+Status RollingBlobFileWriter::Close() {
+    if (closed_) {
+        return Status::OK();
+    }
+    auto s = CloseCurrentWriter();
+    if (!s.ok()) {
+        if (current_writer_) {
+            PAIMON_LOG_WARN(logger_, "Exception occurs when writing file %s. 
Cleaning up: %s",
+                            current_writer_->GetPath().c_str(), 
s.ToString().c_str());
+        }
+        Abort();
+    }
+    closed_ = true;
+    return s;
+}
+
+void RollingBlobFileWriter::Abort() {
+    if (current_writer_ != nullptr) {
+        current_writer_->Abort();
+        current_writer_.reset();
+    }
+    for (auto& abort_executor : closed_writers_) {
+        abort_executor.Abort();
+    }
+    if (blob_writer_ != nullptr) {
+        blob_writer_->Abort();
+        blob_writer_.reset();
+    }
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/rolling_blob_file_writer.h 
b/src/paimon/core/io/rolling_blob_file_writer.h
new file mode 100644
index 0000000..f35f696
--- /dev/null
+++ b/src/paimon/core/io/rolling_blob_file_writer.h
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <set>
+#include <vector>
+
+#include "arrow/array/array_nested.h"
+#include "arrow/c/bridge.h"
+#include "arrow/result.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/core/io/multiple_blob_file_writer.h"
+#include "paimon/core/io/rolling_file_writer.h"
+#include "paimon/metrics.h"
+#include "paimon/record_batch.h"
+
+namespace paimon {
+
+/// A rolling file writer that handles both normal data and blob data. This 
writer creates separate
+/// files for normal columns and blob columns, managing their lifecycle and 
ensuring consistency
+/// between them.
+///
+/// Multiple blob fields are supported. Each blob field is written to its own 
set of blob files
+/// independently via MultipleBlobFileWriter.
+///
+/// <pre>
+/// For example,
+/// given a table schema with normal columns (id INT, name STRING) and blob 
columns (data1 BLOB,
+/// data2 BLOB), this writer will create separate files for (id, name), 
(data1), and (data2).
+/// It will roll files based on the specified target file size, ensuring that 
both normal and blob
+/// files are rolled simultaneously.
+///
+/// Every time a file is rolled, the writer will close the current normal data 
file and blob data
+/// files, so one normal data file may correspond to multiple blob data files.
+///
+/// Normal file1: f1.parquet may include (blob1_1.blob, blob1_2.blob, 
blob2_1.blob)
+/// Normal file2: f2.parquet may include (blob1_3.blob, blob2_2.blob)
+///
+/// </pre>
+class RollingBlobFileWriter
+    : public RollingFileWriter<::ArrowArray*, std::shared_ptr<DataFileMeta>> {
+ public:
+    using MainWriter = SingleFileWriter<::ArrowArray*, 
std::shared_ptr<DataFileMeta>>;
+
+    RollingBlobFileWriter(int64_t target_file_size,
+                          std::function<Result<std::unique_ptr<MainWriter>>()> 
create_file_writer,
+                          const std::shared_ptr<arrow::Schema>& blob_schema,
+                          MultipleBlobFileWriter::BlobWriterCreator 
blob_writer_creator,
+                          const std::shared_ptr<arrow::DataType>& data_type);
+    ~RollingBlobFileWriter() override = default;
+
+    Status Write(::ArrowArray* record) override;
+    void Abort() override;
+    Status Close() override;
+    Result<std::vector<std::shared_ptr<DataFileMeta>>> GetResult() override;
+
+ private:
+    static Status ValidateFileConsistency(
+        const std::shared_ptr<DataFileMeta>& main_data_file_meta,
+        const std::vector<std::shared_ptr<DataFileMeta>>& blob_tagged_metas,
+        int32_t blob_field_count);
+
+    Status CloseCurrentWriter();
+
+    Result<std::shared_ptr<DataFileMeta>> CloseMainWriter();
+    Result<std::vector<std::shared_ptr<DataFileMeta>>> CloseBlobWriter();
+
+    std::shared_ptr<arrow::Schema> blob_schema_;
+    MultipleBlobFileWriter::BlobWriterCreator blob_writer_creator_;
+    std::unique_ptr<MultipleBlobFileWriter> blob_writer_;
+    std::shared_ptr<arrow::DataType> data_type_;
+
+    std::unique_ptr<Logger> logger_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/rolling_blob_file_writer_test.cpp 
b/src/paimon/core/io/rolling_blob_file_writer_test.cpp
new file mode 100644
index 0000000..9e2a4be
--- /dev/null
+++ b/src/paimon/core/io/rolling_blob_file_writer_test.cpp
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/rolling_blob_file_writer.h"
+
+#include <optional>
+#include <string>
+#include <variant>
+
+#include "gtest/gtest.h"
+#include "paimon/common/data/binary_row.h"
+#include "paimon/common/data/data_define.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/manifest/file_source.h"
+#include "paimon/core/stats/simple_stats.h"
+#include "paimon/data/timestamp.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/testing/utils/binary_row_generator.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+class RollingBlobFileWriterTest : public testing::Test {
+ public:
+    void SetUp() override {
+        pool_ = GetDefaultPool();
+    }
+
+ private:
+    std::shared_ptr<MemoryPool> pool_;
+};
+
+TEST_F(RollingBlobFileWriterTest, ValidateFileConsistency) {
+    auto file_meta1 = std::make_shared<DataFileMeta>(
+        "data-xxx.xxx", /*file_size=*/405, /*row_count=*/4,
+        /*min_key=*/BinaryRow::EmptyRow(), /*max_key=*/BinaryRow::EmptyRow(),
+        /*key_stats=*/SimpleStats::EmptyStats(),
+        BinaryRowGenerator::GenerateStats({std::string("str_0"), 1}, 
{std::string("str_3"), 2},
+                                          std::vector<int64_t>({0, 2}), 
pool_.get()),
+        /*min_sequence_number=*/1, /*max_sequence_number=*/1, /*schema_id=*/0,
+        /*level=*/0, /*extra_files=*/std::vector<std::optional<std::string>>(),
+        /*creation_time=*/Timestamp(1724090888706ll, 0),
+        /*delete_row_count=*/0, /*embedded_index=*/nullptr, 
FileSource::Append(),
+        /*value_stats_cols=*/std::nullopt, /*external_path=*/std::nullopt, 
/*first_row_id=*/0,
+        /*write_cols=*/std::vector<std::string>({"f0", "f1"}));
+
+    auto file_meta2 = std::make_shared<DataFileMeta>(
+        "data-xxx.blob", /*file_size=*/764, /*row_count=*/3,
+        /*min_key=*/BinaryRow::EmptyRow(), /*max_key=*/BinaryRow::EmptyRow(),
+        /*key_stats=*/SimpleStats::EmptyStats(),
+        BinaryRowGenerator::GenerateStats({NullType()}, {NullType()}, 
std::vector<int64_t>({-1}),
+                                          pool_.get()),
+        /*min_sequence_number=*/1, /*max_sequence_number=*/1, /*schema_id=*/0,
+        /*level=*/0, /*extra_files=*/std::vector<std::optional<std::string>>(),
+        /*creation_time=*/Timestamp(1724090888706ll, 0),
+        /*delete_row_count=*/0, /*embedded_index=*/nullptr, 
FileSource::Append(),
+        /*value_stats_cols=*/std::nullopt, /*external_path=*/std::nullopt, 
/*first_row_id=*/0,
+        /*write_cols=*/std::vector<std::string>({"blob"}));
+
+    auto file_meta3 = std::make_shared<DataFileMeta>(
+        "data-xxx.blob", /*file_size=*/3023, /*row_count=*/1,
+        /*min_key=*/BinaryRow::EmptyRow(), /*max_key=*/BinaryRow::EmptyRow(),
+        /*key_stats=*/SimpleStats::EmptyStats(),
+        BinaryRowGenerator::GenerateStats({NullType()}, {NullType()}, 
std::vector<int64_t>({-1}),
+                                          pool_.get()),
+        /*min_sequence_number=*/1, /*max_sequence_number=*/1, /*schema_id=*/0,
+        /*level=*/0, /*extra_files=*/std::vector<std::optional<std::string>>(),
+        /*creation_time=*/Timestamp(1724090888706ll, 0),
+        /*delete_row_count=*/0, /*embedded_index=*/nullptr, 
FileSource::Append(),
+        /*value_stats_cols=*/std::nullopt, /*external_path=*/std::nullopt, 
/*first_row_id=*/3,
+        /*write_cols=*/std::vector<std::string>({"blob"}));
+    ASSERT_OK(RollingBlobFileWriter::ValidateFileConsistency(file_meta1, 
{file_meta2, file_meta3},
+                                                             
/*blob_field_count=*/1));
+    
ASSERT_NOK_WITH_MSG(RollingBlobFileWriter::ValidateFileConsistency(file_meta1, 
{file_meta2},
+                                                                       
/*blob_field_count=*/2),
+                        "This is a bug: The row count of main file and blob 
files does not match.");
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/core/io/rolling_file_writer.h 
b/src/paimon/core/io/rolling_file_writer.h
new file mode 100644
index 0000000..fa8a51d
--- /dev/null
+++ b/src/paimon/core/io/rolling_file_writer.h
@@ -0,0 +1,199 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/bridge.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/core/io/file_writer.h"
+#include "paimon/core/io/single_file_writer.h"
+#include "paimon/core/key_value.h"
+#include "paimon/metrics.h"
+#include "paimon/record_batch.h"
+
+namespace paimon {
+
+// Writer to roll over to a new file if the current size exceed the target 
file size.
+template <typename T, typename R>
+class RollingFileWriter : public FileWriter<T, std::vector<R>> {
+ public:
+    RollingFileWriter(
+        int64_t target_file_size,
+        std::function<Result<std::unique_ptr<SingleFileWriter<T, R>>>()> 
create_file_writer)
+        : target_file_size_(target_file_size),
+          create_file_writer(create_file_writer),
+          metrics_(std::make_shared<MetricsImpl>()),
+          logger_(Logger::GetLogger("RollingFileWriter")) {}
+
+    ~RollingFileWriter() = default;
+
+    Status Write(T record) override;
+    void Abort() override;
+    Status Close() override;
+    Result<std::vector<R>> GetResult() override;
+
+    int64_t RecordCount() const override {
+        return record_count_;
+    }
+
+    std::shared_ptr<Metrics> GetMetrics() const override {
+        return metrics_;
+    }
+
+    int64_t TargetFileSize() const {
+        return target_file_size_;
+    }
+
+ protected:
+    static constexpr int32_t CHECK_ROLLING_RECORD_CNT = 1000;
+
+    bool SuggestCheck();
+    Result<bool> NeedRollingFile();
+    Result<std::unique_ptr<SingleFileWriter<T, R>>> NewWriter();
+    Status OpenCurrentWriter();
+
+    int64_t target_file_size_ = 0;
+    std::function<Result<std::unique_ptr<SingleFileWriter<T, R>>>()> 
create_file_writer;
+    std::shared_ptr<Metrics> metrics_;
+
+    int64_t record_count_ = 0;
+    int64_t last_need_rolling_record_count_ = 0;
+    bool closed_ = false;
+
+    std::vector<typename SingleFileWriter<T, R>::AbortExecutor> 
closed_writers_;
+    std::vector<R> results_;
+    std::unique_ptr<SingleFileWriter<T, R>> current_writer_;
+
+ private:
+    Status CloseCurrentWriter();
+
+    std::unique_ptr<Logger> logger_;
+};
+
+template <typename T, typename R>
+bool RollingFileWriter<T, R>::SuggestCheck() {
+    bool suggest_check = false;
+    if (record_count_ - last_need_rolling_record_count_ >= 
CHECK_ROLLING_RECORD_CNT) {
+        suggest_check = true;
+        last_need_rolling_record_count_ = record_count_;
+    }
+    return suggest_check;
+}
+
+template <typename T, typename R>
+Result<bool> RollingFileWriter<T, R>::NeedRollingFile() {
+    return current_writer_->ReachTargetSize(SuggestCheck(), target_file_size_);
+}
+
+template <typename T, typename R>
+Status RollingFileWriter<T, R>::Write(T record) {
+    ScopeGuard guard([this]() -> void { this->Abort(); });
+    // Open the current writer if write the first record or roll over happen 
before.
+    if (PAIMON_UNLIKELY(current_writer_ == nullptr)) {
+        PAIMON_RETURN_NOT_OK(OpenCurrentWriter());
+    }
+    int64_t record_count = 0;
+    if constexpr (std::is_same_v<T, ::ArrowArray*>) {
+        record_count = record->length;
+    } else if constexpr (std::is_same_v<T, KeyValueBatch>) {
+        record_count = record.batch->length;
+    } else {
+        record_count = 1;
+    }
+    PAIMON_RETURN_NOT_OK(current_writer_->Write(std::move(record)));
+    record_count_ += record_count;
+    PAIMON_ASSIGN_OR_RAISE(bool need_rolling_file, NeedRollingFile());
+    if (need_rolling_file) {
+        PAIMON_RETURN_NOT_OK(CloseCurrentWriter());
+    }
+    guard.Release();
+    return Status::OK();
+}
+
+template <typename T, typename R>
+Result<std::vector<R>> RollingFileWriter<T, R>::GetResult() {
+    if (!closed_) {
+        return Status::Invalid("Cannot access the results unless close all 
writers.");
+    }
+    return results_;
+}
+
+template <typename T, typename R>
+Result<std::unique_ptr<SingleFileWriter<T, R>>> RollingFileWriter<T, 
R>::NewWriter() {
+    return create_file_writer();
+}
+
+template <typename T, typename R>
+Status RollingFileWriter<T, R>::OpenCurrentWriter() {
+    PAIMON_ASSIGN_OR_RAISE(current_writer_, NewWriter());
+    if (metrics_) {
+        metrics_->Merge(current_writer_->GetMetrics());
+    }
+    return Status::OK();
+}
+
+template <typename T, typename R>
+Status RollingFileWriter<T, R>::CloseCurrentWriter() {
+    if (current_writer_ == nullptr) {
+        return Status::OK();
+    }
+    std::shared_ptr<Metrics> current_metrics = current_writer_->GetMetrics();
+    PAIMON_RETURN_NOT_OK(current_writer_->Close());
+    PAIMON_ASSIGN_OR_RAISE(auto abort_executor, 
current_writer_->GetAbortExecutor());
+    closed_writers_.push_back(abort_executor);
+    PAIMON_ASSIGN_OR_RAISE(R result, current_writer_->GetResult());
+    results_.push_back(result);
+    current_writer_.reset();
+    if (metrics_) {
+        metrics_->Merge(current_metrics);
+    }
+    return Status::OK();
+}
+
+template <typename T, typename R>
+Status RollingFileWriter<T, R>::Close() {
+    if (closed_) {
+        return Status::OK();
+    }
+    auto s = CloseCurrentWriter();
+    if (!s.ok()) {
+        if (current_writer_) {
+            PAIMON_LOG_WARN(logger_, "Exception occurs when writing file %s. 
Cleaning up: %s",
+                            current_writer_->GetPath().c_str(), 
s.ToString().c_str());
+        }
+        Abort();
+    }
+    closed_ = true;
+    return s;
+}
+
+template <typename T, typename R>
+void RollingFileWriter<T, R>::Abort() {
+    if (current_writer_ != nullptr) {
+        current_writer_->Abort();
+    }
+    for (auto& abort_executor : closed_writers_) {
+        abort_executor.Abort();
+    }
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/single_file_writer.h 
b/src/paimon/core/io/single_file_writer.h
new file mode 100644
index 0000000..2ead8ae
--- /dev/null
+++ b/src/paimon/core/io/single_file_writer.h
@@ -0,0 +1,241 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <cassert>
+#include <cstdint>
+#include <functional>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "arrow/c/abi.h"
+#include "arrow/c/helpers.h"
+#include "fmt/format.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/file_writer.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/writer_builder.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/logging.h"
+#include "paimon/macros.h"
+#include "paimon/record_batch.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+
+namespace paimon {
+
+class RecordBatch;
+class Metrics;
+
+/// A `FileWriter` to produce a single file.
+///
+/// <T> type of records to write.
+/// <R> is the type of result to produce after writing a file.
+template <typename T, typename R>
+class SingleFileWriter : public FileWriter<T, R> {
+ public:
+    /// Abort executor to just have reference of path instead of whole writer.
+    class AbortExecutor {
+     public:
+        AbortExecutor(const std::shared_ptr<FileSystem>& fs, const 
std::string& path)
+            : fs_(fs), path_(path), 
logger_(Logger::GetLogger("AbortExecutor")) {}
+
+        void Abort() {
+            if (fs_) {
+                auto status = fs_->Delete(path_);
+                if (!status.ok()) {
+                    PAIMON_LOG_WARN(logger_, "Exception occurs when deleting 
%s: %s", path_.c_str(),
+                                    status.ToString().c_str());
+                }
+            }
+        }
+
+     private:
+        std::shared_ptr<FileSystem> fs_;
+        std::string path_;
+        std::shared_ptr<Logger> logger_;
+    };
+
+    SingleFileWriter(const std::string& compression,
+                     std::function<Status(T, ::ArrowArray*)> converter)
+        : compression_(compression),
+          converter_(converter),
+          logger_(Logger::GetLogger("SingleFileWriter")) {}
+
+    virtual Status Init(const std::shared_ptr<FileSystem>& fs, const 
std::string& path,
+                        const std::shared_ptr<WriterBuilder>& writer_builder);
+
+    Status Write(T record) override;
+
+    int64_t RecordCount() const override {
+        return record_count_;
+    }
+    void Abort() override;
+    Status Close() override;
+
+    std::shared_ptr<Metrics> GetMetrics() const override {
+        if (writer_) {
+            return writer_->GetWriterMetrics();
+        }
+        return nullptr;
+    }
+
+    Result<bool> ReachTargetSize(bool suggested_check, int64_t target_size);
+
+    Result<AbortExecutor> GetAbortExecutor() const {
+        if (closed_ == false) {
+            return Status::Invalid("Writer should be closed!");
+        }
+        return AbortExecutor(fs_, path_);
+    }
+
+    std::string GetPath() const {
+        return path_;
+    }
+
+ protected:
+    int64_t output_bytes_ = -1;
+    std::string compression_;
+    std::function<Status(T, ArrowArray*)> converter_;
+    std::shared_ptr<FileSystem> fs_;
+    std::shared_ptr<OutputStream> out_;  // nullptr for DirectWriterBuilder
+    bool closed_ = false;
+    std::string path_;
+
+ private:
+    int64_t record_count_ = 0;
+    std::unique_ptr<FormatWriter> writer_;
+
+    std::unique_ptr<Logger> logger_;
+};
+
+template <typename T, typename R>
+Status SingleFileWriter<T, R>::Init(const std::shared_ptr<FileSystem>& fs, 
const std::string& path,
+                                    const std::shared_ptr<WriterBuilder>& 
writer_builder) {
+    ScopeGuard guard([this]() -> void {
+        this->Abort();
+        PAIMON_LOG_WARN(logger_,
+                        "Exception occurs when initializing single file writer 
%s. Cleaning up.",
+                        path_.c_str());
+    });
+    path_ = path;
+    fs_ = fs;
+
+    if (auto specific_fs_writer_builder =
+            
std::dynamic_pointer_cast<SpecificFSWriterBuilder>(writer_builder)) {
+        specific_fs_writer_builder->WithFileSystem(fs);
+    }
+    if (auto direct_writer_builder =
+            std::dynamic_pointer_cast<DirectWriterBuilder>(writer_builder)) {
+        PAIMON_ASSIGN_OR_RAISE(writer_, 
direct_writer_builder->BuildFromPath(path));
+    } else {
+        PAIMON_ASSIGN_OR_RAISE(out_, fs_->Create(path, /*overwrite=*/false));
+        PAIMON_ASSIGN_OR_RAISE(writer_, writer_builder->Build(out_, 
compression_));
+        assert(out_);
+    }
+    assert(writer_);
+    record_count_ = 0;
+    closed_ = false;
+    guard.Release();
+    return Status::OK();
+}
+
+template <typename T, typename R>
+Status SingleFileWriter<T, R>::Write(T record) {
+    if (PAIMON_UNLIKELY(closed_)) {
+        return Status::Invalid("Writer has already closed!");
+    }
+    ScopeGuard guard([this]() -> void { this->Abort(); });
+    int64_t record_count = 0;
+    if (!converter_) {
+        if constexpr (std::is_same_v<T, ::ArrowArray*>) {
+            record_count = record->length;
+            ScopeGuard inner_guard([&record]() { ArrowArrayRelease(record); });
+            PAIMON_RETURN_NOT_OK(writer_->AddBatch(record));
+            inner_guard.Release();
+        } else {
+            return Status::Invalid("converter is not set");
+        }
+    } else {
+        ArrowArray array;
+        ArrowArrayMarkReleased(&array);  // reset array
+        ScopeGuard inner_guard([&array]() { ArrowArrayRelease(&array); });
+        PAIMON_RETURN_NOT_OK(converter_(std::move(record), &array));
+        record_count = array.length;
+        PAIMON_RETURN_NOT_OK(writer_->AddBatch(&array));
+        inner_guard.Release();
+    }
+    record_count_ += record_count;
+    guard.Release();
+    return Status::OK();
+}
+
+template <typename T, typename R>
+Status SingleFileWriter<T, R>::Close() {
+    if (closed_) {
+        return Status::OK();
+    }
+    PAIMON_LOG_DEBUG(logger_, "Closing file %s", path_.c_str());
+    ScopeGuard guard([this]() -> void {
+        this->Abort();
+        PAIMON_LOG_WARN(logger_, "Exception occurs when closing file %s. 
Cleaning up.",
+                        path_.c_str());
+    });
+    PAIMON_RETURN_NOT_OK(writer_->Flush());
+    PAIMON_RETURN_NOT_OK(writer_->Finish());
+    if (out_) {
+        PAIMON_RETURN_NOT_OK(out_->Flush());
+        PAIMON_ASSIGN_OR_RAISE(output_bytes_, out_->GetPos());
+        PAIMON_RETURN_NOT_OK(out_->Close());
+    } else {
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileStatus> file_status, 
fs_->GetFileStatus(path_));
+        output_bytes_ = file_status->GetLen();
+    }
+    closed_ = true;
+    guard.Release();
+    return Status::OK();
+}
+
+template <typename T, typename R>
+Result<bool> SingleFileWriter<T, R>::ReachTargetSize(bool suggested_check, 
int64_t target_size) {
+    return writer_->ReachTargetSize(suggested_check, target_size);
+}
+
+template <typename T, typename R>
+void SingleFileWriter<T, R>::Abort() {
+    if (out_) {
+        auto status = out_->Close();
+        if (!status.ok()) {
+            PAIMON_LOG_WARN(logger_, "Exception occurs when closing %s: %s", 
path_.c_str(),
+                            status.ToString().c_str());
+        }
+    }
+    if (fs_) {
+        auto status = fs_->Delete(path_);
+        if (!status.ok()) {
+            PAIMON_LOG_WARN(logger_, "Exception occurs when closing %s: %s", 
path_.c_str(),
+                            status.ToString().c_str());
+        }
+    }
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/io/single_file_writer_test.cpp 
b/src/paimon/core/io/single_file_writer_test.cpp
new file mode 100644
index 0000000..c3ef9eb
--- /dev/null
+++ b/src/paimon/core/io/single_file_writer_test.cpp
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/single_file_writer.h"
+
+#include <map>
+#include <utility>
+
+#include "arrow/api.h"
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/ipc/json_simple.h"
+#include "gtest/gtest.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/core/core_options.h"
+#include "paimon/defs.h"
+#include "paimon/format/file_format.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+class SimpleSingleFileWriter : public SingleFileWriter<int32_t, bool> {
+ public:
+    SimpleSingleFileWriter(const std::string& compression,
+                           std::function<Status(int32_t, ArrowArray*)> 
converter)
+        : SingleFileWriter<int32_t, bool>(compression, converter) {}
+
+    Result<bool> GetResult() override {
+        return true;
+    }
+};
+
+TEST(SingleFileWriterTest, TestSimple) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    std::string file_path = dir->Str() + "/single-file";
+    auto data_type = arrow::struct_({arrow::field("col", arrow::int32())});
+    auto converter = [&](int32_t value, ::ArrowArray* dest) -> Status {
+        std::string value_str = "[[" + std::to_string(value) + "]]";
+        auto array =
+            arrow::ipc::internal::json::ArrayFromJSON(data_type, 
value_str.c_str()).ValueOrDie();
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*array, dest));
+        return Status::OK();
+    };
+    SimpleSingleFileWriter writer("zstd", converter);
+    ASSERT_OK_AND_ASSIGN(
+        CoreOptions options,
+        CoreOptions::FromMap({{Options::MANIFEST_FORMAT, "orc"}, 
{Options::FILE_FORMAT, "orc"}}));
+    auto file_format = options.GetWriteFileFormat(/*level=*/0);
+    auto file_system = options.GetFileSystem();
+    ArrowSchema arrow_schema;
+    ASSERT_TRUE(arrow::ExportType(*data_type, &arrow_schema).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<WriterBuilder> writer_builder,
+                         file_format->CreateWriterBuilder(&arrow_schema, 
/*batch_size=*/100));
+    ASSERT_OK(writer.Init(file_system, file_path, writer_builder));
+    ASSERT_EQ(file_path, writer.GetPath());
+    ASSERT_OK(writer.Write(100));
+    ASSERT_EQ(1, writer.RecordCount());
+    ASSERT_NOK_WITH_MSG(writer.GetAbortExecutor(), "Writer should be closed");
+    ASSERT_OK(writer.Close());
+    ASSERT_OK_AND_ASSIGN(auto file_status, 
file_system->GetFileStatus(file_path));
+    ASSERT_GT(file_status->GetLen(), 0);
+    ASSERT_OK_AND_ASSIGN(auto abort_executor, writer.GetAbortExecutor());
+    abort_executor.Abort();
+    ASSERT_OK_AND_ASSIGN(auto exist, file_system->Exists(file_path));
+    ASSERT_FALSE(exist);
+}
+
+TEST(SingleFileWriterTest, TestInvalidConvert) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    std::string file_path = dir->Str() + "/single-file";
+    auto data_type = arrow::struct_({arrow::field("col", arrow::int32())});
+    auto converter = [&](int32_t value, ::ArrowArray* dest) -> Status {
+        return Status::Invalid("");
+    };
+    SimpleSingleFileWriter writer("zstd", converter);
+    ASSERT_OK_AND_ASSIGN(
+        CoreOptions options,
+        CoreOptions::FromMap({{Options::MANIFEST_FORMAT, "orc"}, 
{Options::FILE_FORMAT, "orc"}}));
+    auto file_format = options.GetWriteFileFormat(/*level=*/0);
+    auto file_system = options.GetFileSystem();
+    ArrowSchema arrow_schema;
+    ASSERT_TRUE(arrow::ExportType(*data_type, &arrow_schema).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<WriterBuilder> writer_builder,
+                         file_format->CreateWriterBuilder(&arrow_schema, 
/*batch_size=*/100));
+    ASSERT_OK(writer.Init(file_system, file_path, writer_builder));
+    ASSERT_NOK(writer.Write(100));
+    writer.Abort();
+    ASSERT_OK_AND_ASSIGN(auto exist, file_system->Exists(file_path));
+    ASSERT_FALSE(exist);
+}
+
+}  // namespace paimon::test

Reply via email to