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 abf9e66  feat(core): add append-only/key-value file store write and 
compaction metrics (#92)
abf9e66 is described below

commit abf9e6600ab1fc73503c879f88352e1bc4e17d28
Author: Yonghao Fang <[email protected]>
AuthorDate: Mon Jun 22 17:31:28 2026 +0800

    feat(core): add append-only/key-value file store write and compaction 
metrics (#92)
---
 .../operation/append_only_file_store_write.cpp     | 277 +++++++++++++++
 .../core/operation/append_only_file_store_write.h  | 124 +++++++
 .../append_only_file_store_write_test.cpp          | 177 ++++++++++
 .../core/operation/key_value_file_store_write.cpp  | 132 +++++++
 .../core/operation/key_value_file_store_write.h    |  91 +++++
 .../operation/key_value_file_store_write_test.cpp  | 391 +++++++++++++++++++++
 .../core/operation/metrics/compaction_metrics.h    | 284 +++++++++++++++
 .../operation/metrics/compaction_metrics_test.cpp  | 152 ++++++++
 8 files changed, 1628 insertions(+)

diff --git a/src/paimon/core/operation/append_only_file_store_write.cpp 
b/src/paimon/core/operation/append_only_file_store_write.cpp
new file mode 100644
index 0000000..427a0fa
--- /dev/null
+++ b/src/paimon/core/operation/append_only_file_store_write.cpp
@@ -0,0 +1,277 @@
+/*
+ * 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/operation/append_only_file_store_write.h"
+
+#include <vector>
+
+#include "paimon/common/data/binary_row.h"
+#include "paimon/common/table/special_fields.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
+#include "paimon/core/append/append_only_writer.h"
+#include "paimon/core/append/bucketed_append_compact_manager.h"
+#include "paimon/core/compact/noop_compact_manager.h"
+#include "paimon/core/core_options.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_file_path_factory.h"
+#include "paimon/core/io/data_file_writer.h"
+#include "paimon/core/io/rolling_file_writer.h"
+#include "paimon/core/manifest/manifest_file.h"
+#include "paimon/core/manifest/manifest_list.h"
+#include "paimon/core/operation/append_only_file_store_scan.h"
+#include "paimon/core/operation/file_store_scan.h"
+#include "paimon/core/operation/internal_read_context.h"
+#include "paimon/core/operation/raw_file_split_read.h"
+#include "paimon/core/operation/restore_files.h"
+#include "paimon/core/schema/table_schema.h"
+#include "paimon/core/snapshot.h"
+#include "paimon/core/utils/file_store_path_factory.h"
+#include "paimon/core/utils/snapshot_manager.h"
+#include "paimon/executor.h"
+#include "paimon/logging.h"
+#include "paimon/read_context.h"
+#include "paimon/result.h"
+namespace arrow {
+class Schema;
+}  // namespace arrow
+
+namespace paimon {
+class DataFilePathFactory;
+class MemoryPool;
+class SchemaManager;
+
+AppendOnlyFileStoreWrite::AppendOnlyFileStoreWrite(
+    const std::shared_ptr<FileStorePathFactory>& file_store_path_factory,
+    const std::shared_ptr<SnapshotManager>& snapshot_manager,
+    const std::shared_ptr<SchemaManager>& schema_manager, const std::string& 
commit_user,
+    const std::string& root_path, const std::shared_ptr<TableSchema>& 
table_schema,
+    const std::shared_ptr<arrow::Schema>& schema,
+    const std::shared_ptr<arrow::Schema>& write_schema,
+    const std::shared_ptr<arrow::Schema>& partition_schema,
+    const std::shared_ptr<BucketedDvMaintainer::Factory>& 
dv_maintainer_factory,
+    const std::shared_ptr<IOManager>& io_manager, const CoreOptions& options,
+    bool ignore_previous_files, bool is_streaming_mode, bool 
ignore_num_bucket_check,
+    const std::shared_ptr<Executor>& executor, const 
std::shared_ptr<MemoryPool>& pool)
+    : AbstractFileStoreWrite(file_store_path_factory, snapshot_manager, 
schema_manager, commit_user,
+                             root_path, table_schema, schema, write_schema, 
partition_schema,
+                             dv_maintainer_factory, io_manager, options, 
ignore_previous_files,
+                             is_streaming_mode, ignore_num_bucket_check, 
executor, pool),
+      logger_(Logger::GetLogger("AppendOnlyFileStoreWrite")) {
+    write_cols_ = write_schema->field_names();
+    auto schemas = BlobUtils::SeparateBlobSchema(schema_);
+    if (schemas.blob_schema && schemas.blob_schema->num_fields() > 0) {
+        with_blob_ = true;
+    }
+    // optimize write_cols to null in following cases:
+    // 1. write_schema contains all columns
+    // 2. TODO(xinyu.lxy) write_schema contains all columns and append _ROW_ID 
& _SEQUENCE_NUMBER
+    // cols
+    if (schema->Equals(write_schema)) {
+        write_cols_ = std::nullopt;
+    }
+}
+
+AppendOnlyFileStoreWrite::~AppendOnlyFileStoreWrite() = default;
+
+Result<std::unique_ptr<FileStoreScan>> 
AppendOnlyFileStoreWrite::CreateFileStoreScan(
+    const std::shared_ptr<ScanFilter>& scan_filter) const {
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<ManifestList> manifest_list,
+        ManifestList::Create(options_.GetFileSystem(), 
options_.GetManifestFormat(),
+                             options_.GetManifestCompression(), 
file_store_path_factory_, pool_));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<ManifestFile> manifest_file,
+        ManifestFile::Create(options_.GetFileSystem(), 
options_.GetManifestFormat(),
+                             options_.GetManifestCompression(), 
file_store_path_factory_,
+                             options_.GetManifestTargetFileSize(), pool_, 
options_,
+                             partition_schema_));
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileStoreScan> scan,
+                           AppendOnlyFileStoreScan::Create(
+                               snapshot_manager_, schema_manager_, 
manifest_list, manifest_file,
+                               table_schema_, schema_, scan_filter, options_, 
executor_, pool_));
+    return scan;
+}
+
+Result<std::vector<std::shared_ptr<DataFileMeta>>> 
AppendOnlyFileStoreWrite::CompactRewrite(
+    const BinaryRow& partition, int32_t bucket, DeletionVector::Factory 
dv_factory,
+    const std::vector<std::shared_ptr<DataFileMeta>>& to_compact,
+    const std::shared_ptr<CancellationController>& cancellation_controller) {
+    if (to_compact.empty()) {
+        return std::vector<std::shared_ptr<DataFileMeta>>{};
+    }
+
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<BatchReader> reader,
+                           CreateFilesReader(partition, bucket, dv_factory, 
to_compact));
+    auto rewriter =
+        std::make_unique<RollingFileWriter<::ArrowArray*, 
std::shared_ptr<DataFileMeta>>>(
+            options_.GetTargetFileSize(/*has_primary_key=*/false),
+            GetDataFileWriterCreator(partition, bucket, write_schema_, 
write_cols_, to_compact));
+
+    ScopeGuard reader_guard([&]() {
+        if (reader) {
+            reader->Close();
+        }
+    });
+
+    ScopeGuard rewriter_guard([&]() {
+        if (rewriter) {
+            (void)rewriter->Close();
+        }
+    });
+
+    while (true) {
+        if (cancellation_controller->IsCancelled()) {
+            return Status::Cancelled("Compaction cancelled while rewriting 
files.");
+        }
+        PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatch batch, 
reader->NextBatch());
+        if (BatchReader::IsEofBatch(batch)) {
+            break;
+        }
+        auto& [c_array, c_schema] = batch;
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> 
arrow_array,
+                                          arrow::ImportArray(c_array.get(), 
c_schema.get()));
+        auto struct_array = 
std::dynamic_pointer_cast<arrow::StructArray>(arrow_array);
+        if (!struct_array) {
+            return Status::Invalid(
+                "cannot cast array to StructArray in 
CompleteRowKindBatchReader");
+        }
+        PAIMON_ASSIGN_OR_RAISE(struct_array, 
ArrowUtils::RemoveFieldFromStructArray(
+                                                 struct_array, 
SpecialFields::ValueKind().Name()));
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(
+            arrow::ExportArray(*struct_array, c_array.get(), c_schema.get()));
+        ArrowSchemaRelease(c_schema.get());
+        ScopeGuard guard([array = c_array.get()]() { ArrowArrayRelease(array); 
});
+        PAIMON_RETURN_NOT_OK(rewriter->Write(c_array.get()));
+        guard.Release();
+    }
+    rewriter_guard.Release();
+    PAIMON_RETURN_NOT_OK(rewriter->Close());
+    return rewriter->GetResult();
+}
+
+Result<std::shared_ptr<BatchWriter>> AppendOnlyFileStoreWrite::CreateWriter(
+    const BinaryRow& partition, int32_t bucket,
+    const std::vector<std::shared_ptr<DataFileMeta>>& restore_data_files,
+    int64_t restore_max_seq_number, const 
std::shared_ptr<BucketedDvMaintainer>& dv_maintainer) {
+    PAIMON_LOG_DEBUG(logger_, "Creating append only writer for partition %s, 
bucket %d",
+                     partition.ToString().c_str(), bucket);
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DataFilePathFactory> 
data_file_path_factory,
+                           
file_store_path_factory_->CreateDataFilePathFactory(partition, bucket));
+
+    std::shared_ptr<CompactManager> compact_manager;
+    auto schemas = BlobUtils::SeparateBlobSchema(write_schema_);
+    if (options_.WriteOnly() || options_.DataEvolutionEnabled() || 
options_.GetBucket() == -1 ||
+        with_blob_) {
+        compact_manager = std::make_shared<NoopCompactManager>();
+    } else {
+        auto dv_factory =
+            [dv_maintainer](
+                const std::string& file_name) -> 
Result<std::shared_ptr<DeletionVector>> {
+            if (dv_maintainer) {
+                return dv_maintainer->DeletionVectorOf(file_name).value_or(
+                    std::shared_ptr<DeletionVector>());
+            }
+            return std::shared_ptr<DeletionVector>();
+        };
+        auto cancellation_controller = 
std::make_shared<CancellationController>();
+
+        auto rewriter = [this, partition, bucket, dv_factory, 
cancellation_controller](
+                            const std::vector<std::shared_ptr<DataFileMeta>>& 
to_compact)
+            -> Result<std::vector<std::shared_ptr<DataFileMeta>>> {
+            return CompactRewrite(partition, bucket, dv_factory, to_compact,
+                                  cancellation_controller);
+        };
+
+        compact_manager = std::make_shared<BucketedAppendCompactManager>(
+            compact_executor_, restore_data_files, dv_maintainer,
+            options_.GetCompactionMinFileNum(),
+            options_.GetTargetFileSize(/*has_primary_key=*/false),
+            options_.GetCompactionFileSize(/*has_primary_key=*/false),
+            options_.CompactionForceRewriteAllFiles(), rewriter,
+            compaction_metrics_->CreateReporter(partition, bucket), 
cancellation_controller);
+    }
+
+    auto writer = std::make_shared<AppendOnlyWriter>(
+        options_, table_schema_->Id(), write_schema_, write_cols_, 
restore_max_seq_number,
+        data_file_path_factory, compact_manager, pool_);
+    return std::shared_ptr<BatchWriter>(writer);
+}
+
+AppendOnlyFileStoreWrite::SingleFileWriterCreator
+AppendOnlyFileStoreWrite::GetDataFileWriterCreator(
+    const BinaryRow& partition, int32_t bucket, const 
std::shared_ptr<arrow::Schema>& schema,
+    const std::optional<std::vector<std::string>>& write_cols,
+    const std::vector<std::shared_ptr<DataFileMeta>>& to_compact) const {
+    return
+        [this, partition, bucket, schema, write_cols, to_compact]()
+            -> Result<
+                std::unique_ptr<SingleFileWriter<::ArrowArray*, 
std::shared_ptr<DataFileMeta>>>> {
+            ::ArrowSchema arrow_schema;
+            ScopeGuard guard([&arrow_schema]() { 
ArrowSchemaRelease(&arrow_schema); });
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema, 
&arrow_schema));
+            auto format = options_.GetFileFormat();
+            PAIMON_ASSIGN_OR_RAISE(
+                std::shared_ptr<WriterBuilder> writer_builder,
+                format->CreateWriterBuilder(&arrow_schema, 
options_.GetWriteBatchSize()));
+            writer_builder->WithMemoryPool(pool_);
+
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema, 
&arrow_schema));
+            PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FormatStatsExtractor> 
stats_extractor,
+                                   
format->CreateStatsExtractor(&arrow_schema));
+            PAIMON_ASSIGN_OR_RAISE(
+                std::shared_ptr<DataFilePathFactory> data_file_path_factory,
+                file_store_path_factory_->CreateDataFilePathFactory(partition, 
bucket));
+            auto writer = std::make_unique<DataFileWriter>(
+                options_.GetFileCompression(), 
std::function<Status(ArrowArray*, ArrowArray*)>(),
+                table_schema_->Id(),
+                
std::make_shared<LongCounter>(to_compact[0]->min_sequence_number),
+                FileSource::Compact(), stats_extractor, 
data_file_path_factory->IsExternalPath(),
+                write_cols, pool_);
+            PAIMON_RETURN_NOT_OK(writer->Init(options_.GetFileSystem(),
+                                              
data_file_path_factory->NewPath(), writer_builder));
+            return writer;
+        };
+}
+
+Result<std::unique_ptr<BatchReader>> 
AppendOnlyFileStoreWrite::CreateFilesReader(
+    const BinaryRow& partition, int32_t bucket, DeletionVector::Factory 
dv_factory,
+    const std::vector<std::shared_ptr<DataFileMeta>>& files) const {
+    ReadContextBuilder context_builder(root_path_);
+    context_builder.SetOptions(options_.ToMap())
+        .WithFileSystem(options_.GetFileSystem())
+        .EnablePrefetch(true)
+        .SetPrefetchMaxParallelNum(1)
+        .SetPrefetchBatchCount(3)
+        .WithMemoryPool(pool_);
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ReadContext> read_context, 
context_builder.Finish());
+    std::map<std::string, std::string> options = options_.ToMap();
+    // TODO(xinyu.lxy): temporarily disabled pre-buffer for parquet, which may 
cause high
+    // memory usage during compaction. Will fix via parquet format refactor.
+    auto new_options = options;
+    if (new_options.find("parquet.read.enable-pre-buffer") == 
new_options.end()) {
+        new_options["parquet.read.enable-pre-buffer"] = "false";
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InternalReadContext> 
internal_read_context,
+                           InternalReadContext::Create(read_context, 
table_schema_, new_options));
+    auto read = std::make_unique<RawFileSplitRead>(file_store_path_factory_, 
internal_read_context,
+                                                   pool_, compact_executor_);
+
+    return read->CreateReader(partition, bucket, files, dv_factory);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/operation/append_only_file_store_write.h 
b/src/paimon/core/operation/append_only_file_store_write.h
new file mode 100644
index 0000000..d1aacb5
--- /dev/null
+++ b/src/paimon/core/operation/append_only_file_store_write.h
@@ -0,0 +1,124 @@
+/*
+ * 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 <optional>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include "arrow/type.h"
+#include "paimon/common/data/binary_row.h"
+#include "paimon/core/compact/cancellation_controller.h"
+#include "paimon/core/core_options.h"
+#include "paimon/core/deletionvectors/deletion_vector.h"
+#include "paimon/core/io/single_file_writer.h"
+#include "paimon/core/operation/abstract_file_store_write.h"
+#include "paimon/core/table/bucket_mode.h"
+#include "paimon/file_store_write.h"
+#include "paimon/logging.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/result.h"
+#include "paimon/type_fwd.h"
+
+struct ArrowSchema;
+
+namespace arrow {
+class Schema;
+}  // namespace arrow
+
+namespace paimon {
+
+struct DataFileMeta;
+class BatchWriter;
+class BucketedDvMaintainer;
+class FileStorePathFactory;
+class FileStoreScan;
+class SnapshotManager;
+class ScanFilter;
+class MetricsImpl;
+class BinaryRow;
+class CoreOptions;
+class Executor;
+class Logger;
+class MemoryPool;
+class SchemaManager;
+class TableSchema;
+class IOManager;
+
+class AppendOnlyFileStoreWrite : public AbstractFileStoreWrite {
+ public:
+    AppendOnlyFileStoreWrite(
+        const std::shared_ptr<FileStorePathFactory>& file_store_path_factory,
+        const std::shared_ptr<SnapshotManager>& snapshot_manager,
+        const std::shared_ptr<SchemaManager>& schema_manager, const 
std::string& commit_user,
+        const std::string& root_path, const std::shared_ptr<TableSchema>& 
table_schema,
+        const std::shared_ptr<arrow::Schema>& schema,
+        const std::shared_ptr<arrow::Schema>& write_schema,
+        const std::shared_ptr<arrow::Schema>& partition_schema,
+        const std::shared_ptr<BucketedDvMaintainer::Factory>& 
dv_maintainer_factory,
+        const std::shared_ptr<IOManager>& io_manager, const CoreOptions& 
options,
+        bool ignore_previous_files, bool is_streaming_mode, bool 
ignore_num_bucket_check,
+        const std::shared_ptr<Executor>& executor, const 
std::shared_ptr<MemoryPool>& pool);
+    ~AppendOnlyFileStoreWrite() override;
+
+    /// Rewrites the given files into new compacted files.
+    ///
+    /// @param partition The partition of the files.
+    /// @param bucket The bucket number.
+    /// @param dv_factory Factory for creating deletion vectors (nullptr if DV 
is disabled).
+    /// @param to_compact The files to compact.
+    /// @param cancellation_controller Controller to cancel the compaction.
+    /// @return Result containing the new compacted files, or an error Status.
+    Result<std::vector<std::shared_ptr<DataFileMeta>>> CompactRewrite(
+        const BinaryRow& partition, int32_t bucket, DeletionVector::Factory 
dv_factory,
+        const std::vector<std::shared_ptr<DataFileMeta>>& to_compact,
+        const std::shared_ptr<CancellationController>& 
cancellation_controller);
+
+ private:
+    using SingleFileWriterCreator = std::function<
+        Result<std::unique_ptr<SingleFileWriter<::ArrowArray*, 
std::shared_ptr<DataFileMeta>>>>()>;
+
+    Result<std::shared_ptr<BatchWriter>> CreateWriter(
+        const BinaryRow& partition, int32_t bucket,
+        const std::vector<std::shared_ptr<DataFileMeta>>& restore_data_files,
+        int64_t restore_max_seq_number,
+        const std::shared_ptr<BucketedDvMaintainer>& dv_maintainer) override;
+
+    Result<std::unique_ptr<FileStoreScan>> CreateFileStoreScan(
+        const std::shared_ptr<ScanFilter>& filter) const override;
+
+    SingleFileWriterCreator GetDataFileWriterCreator(
+        const BinaryRow& partition, int32_t bucket, const 
std::shared_ptr<arrow::Schema>& schema,
+        const std::optional<std::vector<std::string>>& write_cols,
+        const std::vector<std::shared_ptr<DataFileMeta>>& to_compact) const;
+
+    Result<std::unique_ptr<BatchReader>> CreateFilesReader(
+        const BinaryRow& partition, int32_t bucket, DeletionVector::Factory 
dv_factory,
+        const std::vector<std::shared_ptr<DataFileMeta>>& files) const;
+
+    std::optional<std::vector<std::string>> write_cols_;
+    bool with_blob_ = false;
+    std::unique_ptr<Logger> logger_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/operation/append_only_file_store_write_test.cpp 
b/src/paimon/core/operation/append_only_file_store_write_test.cpp
new file mode 100644
index 0000000..09b9e28
--- /dev/null
+++ b/src/paimon/core/operation/append_only_file_store_write_test.cpp
@@ -0,0 +1,177 @@
+/*
+ * 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/operation/append_only_file_store_write.h"
+
+#include <cstddef>
+#include <map>
+#include <vector>
+
+#include "arrow/array/array_base.h"
+#include "arrow/array/builder_binary.h"
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "arrow/status.h"
+#include "arrow/type.h"
+#include "gtest/gtest.h"
+#include "paimon/catalog/catalog.h"
+#include "paimon/catalog/identifier.h"
+#include "paimon/common/data/binary_row.h"
+#include "paimon/common/data/binary_row_writer.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/operation/restore_files.h"
+#include "paimon/core/snapshot.h"
+#include "paimon/core/utils/snapshot_manager.h"
+#include "paimon/file_store_write.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/record_batch.h"
+#include "paimon/status.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/write_context.h"
+
+namespace paimon::test {
+
+class AppendOnlyFileStoreWriteTest : public testing::Test {
+ public:
+    void SetUp() override {
+        fields_ = {arrow::field("f0", arrow::boolean()),
+                   arrow::field("f1", arrow::int8()),
+                   arrow::field("f2", arrow::int8()),
+                   arrow::field("f3", arrow::int16()),
+                   arrow::field("f4", arrow::int16()),
+                   arrow::field("f5", arrow::int32()),
+                   arrow::field("f6", arrow::int32()),
+                   arrow::field("f7", arrow::int64()),
+                   arrow::field("f8", arrow::int64()),
+                   arrow::field("f9", arrow::float32()),
+                   arrow::field("f10", arrow::float64()),
+                   arrow::field("f11", arrow::utf8()),
+                   arrow::field("f12", arrow::binary()),
+                   arrow::field("non-partition-field", arrow::int32())};
+        commit_user_ = "test_commit_user";
+    }
+
+ private:
+    arrow::FieldVector fields_;
+    std::string commit_user_;
+};
+
+TEST_F(AppendOnlyFileStoreWriteTest, TestWriteWithInvalidBatch) {
+    {
+        arrow::Schema typed_schema(fields_);
+        ::ArrowSchema schema;
+        ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+        auto dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir);
+
+        ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+        ASSERT_OK(catalog->CreateDatabase("foo", {}, 
/*ignore_if_exists=*/false));
+        ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema, 
/*partition_keys=*/{},
+                                       /*primary_keys=*/{}, /*options=*/{},
+                                       /*ignore_if_exists=*/false));
+
+        WriteContextBuilder builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"), commit_user_);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
builder.Finish());
+        ASSERT_OK_AND_ASSIGN(auto file_store_write,
+                             FileStoreWrite::Create(std::move(write_context)));
+        ASSERT_NOK_WITH_MSG(file_store_write->Write(nullptr), "batch is null 
pointer");
+    }
+    {
+        arrow::Schema typed_schema(fields_);
+        ::ArrowSchema schema;
+        ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+        auto dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir);
+        ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+        ASSERT_OK(catalog->CreateDatabase("foo", {}, 
/*ignore_if_exists=*/false));
+        ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema, 
/*partition_keys=*/{},
+                                       /*primary_keys=*/{}, /*options=*/{},
+                                       /*ignore_if_exists=*/false));
+
+        WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"),
+                                            commit_user_);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
+        ASSERT_OK_AND_ASSIGN(auto file_store_write,
+                             FileStoreWrite::Create(std::move(write_context)));
+        auto array = std::make_shared<arrow::Array>();
+        arrow::StringBuilder builder;
+        for (size_t j = 0; j < 100; j++) {
+            ASSERT_TRUE(builder.Append(std::to_string(j)).ok());
+        }
+        ASSERT_TRUE(builder.Finish(&array).ok());
+        ::ArrowArray arrow_array;
+        ASSERT_TRUE(arrow::ExportArray(*array, &arrow_array).ok());
+        RecordBatchBuilder batch_builder(&arrow_array);
+        ASSERT_OK_AND_ASSIGN(
+            std::unique_ptr<RecordBatch> batch,
+            batch_builder.SetBucket(1).SetPartition({{"f0", "true"}, {"f3", 
"1"}}).Finish());
+        ASSERT_NOK_WITH_MSG(file_store_write->Write(std::move(batch)),
+                            "batch bucket is 1 while options bucket is -1");
+        ArrowArrayRelease(&arrow_array);
+    }
+}
+
+TEST_F(AppendOnlyFileStoreWriteTest, 
TestGetMaxSequenceNumberFromMultiPartition) {
+    WriteContextBuilder builder(
+        paimon::test::GetDataDir() +
+            
"/orc/multi_partition_append_table.db/multi_partition_append_table/",
+        commit_user_);
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<WriteContext> write_context,
+        builder.AddOption("file.format", "orc").AddOption("manifest.format", 
"orc").Finish());
+    ASSERT_OK_AND_ASSIGN(auto file_store_write, 
FileStoreWrite::Create(std::move(write_context)));
+    auto write = 
dynamic_cast<AppendOnlyFileStoreWrite*>(file_store_write.get());
+    auto pool = GetDefaultPool();
+    {
+        BinaryRow partition(2);
+        BinaryRowWriter writer(&partition, 20, pool.get());
+        writer.WriteInt(0, 20);
+        writer.WriteInt(1, 1);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<RestoreFiles> restore_files,
+                             write->ScanExistingFileMetas(partition,
+                                                          /*bucket=*/0));
+        ASSERT_EQ(-1, restore_files->TotalBuckets().value());
+        ASSERT_EQ(0, 
DataFileMeta::GetMaxSequenceNumber(restore_files->DataFiles()));
+    }
+    {
+        BinaryRow partition(2);
+        BinaryRowWriter writer(&partition, 20, pool.get());
+        writer.WriteInt(0, 10);
+        writer.WriteInt(1, 0);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<RestoreFiles> restore_files,
+                             write->ScanExistingFileMetas(partition,
+                                                          /*bucket=*/0));
+        ASSERT_EQ(-1, restore_files->TotalBuckets().value());
+        ASSERT_EQ(2, 
DataFileMeta::GetMaxSequenceNumber(restore_files->DataFiles()));
+    }
+    {
+        BinaryRow partition(2);
+        BinaryRowWriter writer(&partition, 20, pool.get());
+        writer.WriteInt(0, 10);
+        writer.WriteInt(1, 0);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<RestoreFiles> restore_files,
+                             write->ScanExistingFileMetas(partition,
+                                                          /*bucket=*/1));
+        ASSERT_EQ(std::nullopt, restore_files->TotalBuckets());
+        ASSERT_EQ(-1, 
DataFileMeta::GetMaxSequenceNumber(restore_files->DataFiles()));
+    }
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/core/operation/key_value_file_store_write.cpp 
b/src/paimon/core/operation/key_value_file_store_write.cpp
new file mode 100644
index 0000000..0f51199
--- /dev/null
+++ b/src/paimon/core/operation/key_value_file_store_write.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/operation/key_value_file_store_write.h"
+
+#include <vector>
+
+#include "paimon/common/data/binary_row.h"
+#include "paimon/core/core_options.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/manifest/manifest_file.h"
+#include "paimon/core/manifest/manifest_list.h"
+#include "paimon/core/mergetree/levels.h"
+#include "paimon/core/mergetree/merge_tree_writer.h"
+#include "paimon/core/operation/file_store_scan.h"
+#include "paimon/core/operation/key_value_file_store_scan.h"
+#include "paimon/core/schema/table_schema.h"
+#include "paimon/core/utils/file_store_path_factory.h"
+#include "paimon/core/utils/primary_key_table_utils.h"
+#include "paimon/core/utils/snapshot_manager.h"
+
+namespace arrow {
+class Schema;
+}  // namespace arrow
+
+namespace paimon {
+class DataFilePathFactory;
+class Executor;
+class MemoryPool;
+struct KeyValue;
+template <typename T>
+class MergeFunctionWrapper;
+
+KeyValueFileStoreWrite::KeyValueFileStoreWrite(
+    const std::shared_ptr<FileStorePathFactory>& file_store_path_factory,
+    const std::shared_ptr<SnapshotManager>& snapshot_manager,
+    const std::shared_ptr<SchemaManager>& schema_manager, const std::string& 
commit_user,
+    const std::string& root_path, const std::shared_ptr<TableSchema>& 
table_schema,
+    const std::shared_ptr<arrow::Schema>& schema,
+    const std::shared_ptr<arrow::Schema>& partition_schema,
+    const std::shared_ptr<BucketedDvMaintainer::Factory>& 
dv_maintainer_factory,
+    const std::shared_ptr<IOManager>& io_manager,
+    const std::shared_ptr<FieldsComparator>& key_comparator,
+    const std::shared_ptr<FieldsComparator>& user_defined_seq_comparator,
+    const std::shared_ptr<MergeFunctionWrapper<KeyValue>>& 
merge_function_wrapper,
+    const CoreOptions& options, bool ignore_previous_files, bool 
is_streaming_mode,
+    bool ignore_num_bucket_check, bool enable_multi_thread_spill,
+    const std::shared_ptr<Executor>& executor, const 
std::shared_ptr<MemoryPool>& pool)
+    : AbstractFileStoreWrite(file_store_path_factory, snapshot_manager, 
schema_manager, commit_user,
+                             root_path, table_schema, schema, 
/*write_schema=*/schema,
+                             partition_schema, dv_maintainer_factory, 
io_manager, options,
+                             ignore_previous_files, is_streaming_mode, 
ignore_num_bucket_check,
+                             executor, pool),
+      enable_multi_thread_spill_(enable_multi_thread_spill),
+      key_comparator_(key_comparator),
+      user_defined_seq_comparator_(user_defined_seq_comparator),
+      merge_function_wrapper_(merge_function_wrapper),
+      
compact_manager_factory_(std::make_unique<MergeTreeCompactManagerFactory>(
+          options_, key_comparator_, user_defined_seq_comparator_, 
compaction_metrics_,
+          table_schema_, schema_, schema_manager_, io_manager_, cache_manager_,
+          file_store_path_factory_, root_path_, pool_)),
+      logger_(Logger::GetLogger("KeyValueFileStoreWrite")) {}
+
+Result<std::unique_ptr<FileStoreScan>> 
KeyValueFileStoreWrite::CreateFileStoreScan(
+    const std::shared_ptr<ScanFilter>& scan_filter) const {
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<ManifestList> manifest_list,
+        ManifestList::Create(options_.GetFileSystem(), 
options_.GetManifestFormat(),
+                             options_.GetManifestCompression(), 
file_store_path_factory_, pool_));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<ManifestFile> manifest_file,
+        ManifestFile::Create(options_.GetFileSystem(), 
options_.GetManifestFormat(),
+                             options_.GetManifestCompression(), 
file_store_path_factory_,
+                             options_.GetManifestTargetFileSize(), pool_, 
options_,
+                             partition_schema_));
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileStoreScan> scan,
+                           KeyValueFileStoreScan::Create(
+                               snapshot_manager_, schema_manager_, 
manifest_list, manifest_file,
+                               table_schema_, schema_, scan_filter, options_, 
executor_, pool_));
+    return scan;
+}
+
+Result<std::shared_ptr<BatchWriter>> KeyValueFileStoreWrite::CreateWriter(
+    const BinaryRow& partition, int32_t bucket,
+    const std::vector<std::shared_ptr<DataFileMeta>>& restore_data_files,
+    int64_t restore_max_seq_number, const 
std::shared_ptr<BucketedDvMaintainer>& dv_maintainer) {
+    PAIMON_LOG_DEBUG(logger_, "Creating key value writer for partition %s, 
bucket %d",
+                     partition.ToString().c_str(), bucket);
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DataFilePathFactory> 
data_file_path_factory,
+                           
file_store_path_factory_->CreateDataFilePathFactory(partition, bucket));
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::string> trimmed_primary_keys,
+                           table_schema_->TrimmedPrimaryKeys());
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<Levels> levels,
+        Levels::Create(key_comparator_, restore_data_files, 
options_.GetNumLevels()));
+    auto compact_strategy = compact_manager_factory_->CreateCompactStrategy();
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<CompactManager> compact_manager,
+        compact_manager_factory_->CreateCompactManager(partition, bucket, 
compact_strategy,
+                                                       compact_executor_, 
levels, dv_maintainer));
+
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<MergeTreeWriter> writer,
+        MergeTreeWriter::Create(
+            restore_max_seq_number, trimmed_primary_keys, 
data_file_path_factory, key_comparator_,
+            user_defined_seq_comparator_, merge_function_wrapper_, 
table_schema_->Id(), schema_,
+            options_, compact_manager, io_manager_, 
enable_multi_thread_spill_, pool_));
+    return writer;
+}
+
+Status KeyValueFileStoreWrite::Close() {
+    PAIMON_RETURN_NOT_OK(AbstractFileStoreWrite::Close());
+    compact_manager_factory_->Close();
+    return Status::OK();
+}
+
+}  // namespace paimon
diff --git a/src/paimon/core/operation/key_value_file_store_write.h 
b/src/paimon/core/operation/key_value_file_store_write.h
new file mode 100644
index 0000000..1445759
--- /dev/null
+++ b/src/paimon/core/operation/key_value_file_store_write.h
@@ -0,0 +1,91 @@
+/*
+ * 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 <string>
+#include <utility>
+
+#include "paimon/core/mergetree/compact/merge_function_wrapper.h"
+#include "paimon/core/mergetree/compact/merge_tree_compact_manager_factory.h"
+#include "paimon/core/operation/abstract_file_store_write.h"
+#include "paimon/core/utils/batch_writer.h"
+#include "paimon/logging.h"
+#include "paimon/result.h"
+
+namespace arrow {
+class Schema;
+}  // namespace arrow
+
+namespace paimon {
+
+class FieldsComparator;
+class FileStoreScan;
+class ScanFilter;
+class BinaryRow;
+class FileStorePathFactory;
+class SnapshotManager;
+class SchemaManager;
+class TableSchema;
+class IOManager;
+struct KeyValue;
+template <typename T>
+class MergeFunctionWrapper;
+
+class KeyValueFileStoreWrite : public AbstractFileStoreWrite {
+ public:
+    KeyValueFileStoreWrite(
+        const std::shared_ptr<FileStorePathFactory>& file_store_path_factory,
+        const std::shared_ptr<SnapshotManager>& snapshot_manager,
+        const std::shared_ptr<SchemaManager>& schema_manager, const 
std::string& commit_user,
+        const std::string& root_path, const std::shared_ptr<TableSchema>& 
table_schema,
+        const std::shared_ptr<arrow::Schema>& schema,
+        const std::shared_ptr<arrow::Schema>& partition_schema,
+        const std::shared_ptr<BucketedDvMaintainer::Factory>& 
dv_maintainer_factory,
+        const std::shared_ptr<IOManager>& io_manager,
+        const std::shared_ptr<FieldsComparator>& key_comparator,
+        const std::shared_ptr<FieldsComparator>& user_defined_seq_comparator,
+        const std::shared_ptr<MergeFunctionWrapper<KeyValue>>& 
merge_function_wrapper,
+        const CoreOptions& options, bool ignore_previous_files, bool 
is_streaming_mode,
+        bool ignore_num_bucket_check, bool enable_multi_thread_spill,
+        const std::shared_ptr<Executor>& executor, const 
std::shared_ptr<MemoryPool>& pool);
+
+    Status Close() override;
+
+ private:
+    Result<std::shared_ptr<BatchWriter>> CreateWriter(
+        const BinaryRow& partition, int32_t bucket,
+        const std::vector<std::shared_ptr<DataFileMeta>>& restore_data_files,
+        int64_t restore_max_seq_number,
+        const std::shared_ptr<BucketedDvMaintainer>& dv_maintainer) override;
+
+    Result<std::unique_ptr<FileStoreScan>> CreateFileStoreScan(
+        const std::shared_ptr<ScanFilter>& filter) const override;
+
+ private:
+    bool enable_multi_thread_spill_;
+    std::shared_ptr<FieldsComparator> key_comparator_;
+    std::shared_ptr<FieldsComparator> user_defined_seq_comparator_;
+    std::shared_ptr<MergeFunctionWrapper<KeyValue>> merge_function_wrapper_;
+    std::unique_ptr<MergeTreeCompactManagerFactory> compact_manager_factory_;
+    std::unique_ptr<Logger> logger_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/operation/key_value_file_store_write_test.cpp 
b/src/paimon/core/operation/key_value_file_store_write_test.cpp
new file mode 100644
index 0000000..be4c715
--- /dev/null
+++ b/src/paimon/core/operation/key_value_file_store_write_test.cpp
@@ -0,0 +1,391 @@
+/*
+ * 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/operation/key_value_file_store_write.h"
+
+#include <cstddef>
+#include <map>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/array/array_base.h"
+#include "arrow/array/builder_binary.h"
+#include "arrow/array/builder_nested.h"
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "arrow/type.h"
+#include "gtest/gtest.h"
+#include "paimon/catalog/catalog.h"
+#include "paimon/catalog/identifier.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/core/table/sink/commit_message_impl.h"
+#include "paimon/file_store_write.h"
+#include "paimon/record_batch.h"
+#include "paimon/status.h"
+#include "paimon/testing/utils/test_helper.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/write_context.h"
+
+namespace paimon::test {
+
+class KeyValueFileStoreWriteTest : public ::testing::Test {
+ protected:
+    Result<std::unique_ptr<FileStoreWrite>> CreateSingleStringFileStoreWrite(
+        const std::map<std::string, std::string>& table_options, bool 
with_temp_directory) {
+        auto fields = {arrow::field("f0", arrow::utf8(), /*nullable=*/false)};
+        arrow::Schema typed_schema(fields);
+        ::ArrowSchema schema;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(typed_schema, 
&schema));
+
+        auto dir = UniqueTestDirectory::Create();
+        if (!dir) {
+            return Status::Invalid("failed to create test directory");
+        }
+        PAIMON_ASSIGN_OR_RAISE(auto catalog, Catalog::Create(dir->Str(), {}));
+        PAIMON_RETURN_NOT_OK(catalog->CreateDatabase("foo", {}, 
/*ignore_if_exists=*/false));
+        PAIMON_RETURN_NOT_OK(catalog->CreateTable(Identifier("foo", "bar"), 
&schema,
+                                                  /*partition_keys=*/{},
+                                                  /*primary_keys=*/{"f0"}, 
table_options,
+                                                  /*ignore_if_exists=*/false));
+
+        WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"), "test");
+        if (with_temp_directory) {
+            context_builder.WithTempDirectory(dir->Str());
+        }
+
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<WriteContext> write_context,
+                               context_builder.Finish());
+        return FileStoreWrite::Create(std::move(write_context));
+    }
+
+    Status WriteSingleStringRow(FileStoreWrite* file_store_write, int32_t 
bucket,
+                                const std::string& value) {
+        auto fields = {arrow::field("f0", arrow::utf8(), /*nullable=*/false)};
+        auto struct_type = arrow::struct_(fields);
+        arrow::StructBuilder struct_builder(struct_type, 
arrow::default_memory_pool(),
+                                            
{std::make_shared<arrow::StringBuilder>()});
+        auto string_builder = 
static_cast<arrow::StringBuilder*>(struct_builder.field_builder(0));
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Append());
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(string_builder->Append(value));
+
+        std::shared_ptr<arrow::Array> array;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Finish(&array));
+        ::ArrowArray arrow_array;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*array, 
&arrow_array));
+
+        RecordBatchBuilder batch_builder(&arrow_array);
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<RecordBatch> batch,
+                               batch_builder.SetBucket(bucket).Finish());
+        Status write_status = file_store_write->Write(std::move(batch));
+        if (!ArrowArrayIsReleased(&arrow_array)) {
+            ArrowArrayRelease(&arrow_array);
+        }
+        return write_status;
+    }
+};
+
+TEST_F(KeyValueFileStoreWriteTest, TestWriteWithInvalidBatch) {
+    auto fields = {
+        arrow::field("f0", arrow::boolean()),  arrow::field("f1", 
arrow::int8()),
+        arrow::field("f2", arrow::int8()),     arrow::field("f3", 
arrow::int16()),
+        arrow::field("f4", arrow::int16()),    arrow::field("f5", 
arrow::int32()),
+        arrow::field("f6", arrow::int32()),    arrow::field("f7", 
arrow::int64()),
+        arrow::field("f8", arrow::int64()),    arrow::field("f9", 
arrow::float32()),
+        arrow::field("f10", arrow::float64()), arrow::field("f11", 
arrow::utf8()),
+        arrow::field("f12", arrow::binary()),  
arrow::field("non-partition-field", arrow::int32())};
+    std::string commit_user = "test";
+    {
+        arrow::Schema typed_schema(fields);
+        ::ArrowSchema schema;
+        ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+        auto dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir);
+
+        ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+        ASSERT_OK(catalog->CreateDatabase("foo", {}, 
/*ignore_if_exists=*/false));
+        ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema, 
/*partition_keys=*/{},
+                                       /*primary_keys=*/{"f1"}, 
/*options=*/{{"bucket", "1"}},
+                                       /*ignore_if_exists=*/false));
+
+        WriteContextBuilder builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"), commit_user);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
builder.Finish());
+        ASSERT_OK_AND_ASSIGN(auto file_store_write,
+                             FileStoreWrite::Create(std::move(write_context)));
+        ASSERT_NOK_WITH_MSG(file_store_write->Write(nullptr), "batch is null 
pointer");
+    }
+    {
+        arrow::Schema typed_schema(fields);
+        ::ArrowSchema schema;
+        ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+        auto dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir);
+        ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+        ASSERT_OK(catalog->CreateDatabase("foo", {}, 
/*ignore_if_exists=*/false));
+        ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema, 
/*partition_keys=*/{},
+                                       /*primary_keys=*/{"f1"}, 
/*options=*/{{"bucket", "-2"}},
+                                       /*ignore_if_exists=*/false));
+
+        WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"),
+                                            commit_user);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
+        ASSERT_OK_AND_ASSIGN(auto file_store_write,
+                             FileStoreWrite::Create(std::move(write_context)));
+        auto array = std::make_shared<arrow::Array>();
+        arrow::StringBuilder builder;
+        for (size_t j = 0; j < 100; j++) {
+            ASSERT_TRUE(builder.Append(std::to_string(j)).ok());
+        }
+        ASSERT_TRUE(builder.Finish(&array).ok());
+        ::ArrowArray arrow_array;
+        ASSERT_TRUE(arrow::ExportArray(*array, &arrow_array).ok());
+        RecordBatchBuilder batch_builder(&arrow_array);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
+                             batch_builder.SetBucket(1).Finish());
+        ASSERT_NOK_WITH_MSG(file_store_write->Write(std::move(batch)),
+                            "batch bucket is 1 while options bucket is -2");
+        ArrowArrayRelease(&arrow_array);
+    }
+    {
+        arrow::Schema typed_schema(fields);
+        ::ArrowSchema schema;
+        ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+        auto dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir);
+        ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+        ASSERT_OK(catalog->CreateDatabase("foo", {}, 
/*ignore_if_exists=*/false));
+        ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema, 
/*partition_keys=*/{},
+                                       /*primary_keys=*/{"f1"}, 
/*options=*/{{"bucket", "2"}},
+                                       /*ignore_if_exists=*/false));
+
+        WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"),
+                                            commit_user);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
+        ASSERT_OK_AND_ASSIGN(auto file_store_write,
+                             FileStoreWrite::Create(std::move(write_context)));
+        auto array = std::make_shared<arrow::Array>();
+        arrow::StringBuilder builder;
+        for (size_t j = 0; j < 100; j++) {
+            ASSERT_TRUE(builder.Append(std::to_string(j)).ok());
+        }
+        ASSERT_TRUE(builder.Finish(&array).ok());
+        ::ArrowArray arrow_array;
+        ASSERT_TRUE(arrow::ExportArray(*array, &arrow_array).ok());
+        RecordBatchBuilder batch_builder(&arrow_array);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
+                             batch_builder.SetBucket(3).Finish());
+        ASSERT_NOK_WITH_MSG(
+            file_store_write->Write(std::move(batch)),
+            "fixed bucketed mode must specify a bucket which in [0, 2) in 
RecordBatch");
+        ArrowArrayRelease(&arrow_array);
+    }
+}
+
+TEST_F(KeyValueFileStoreWriteTest, 
TestPrepareCommitShouldSucceedWhenLookupEnabledWithIOManager) {
+    ASSERT_OK_AND_ASSIGN(
+        auto file_store_write,
+        CreateSingleStringFileStoreWrite({{"bucket", "1"}, 
{Options::FORCE_LOOKUP, "true"}},
+                                         /*with_temp_directory=*/true));
+
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"k1"));
+    ASSERT_OK_AND_ASSIGN(auto commit_messages,
+                         
file_store_write->PrepareCommit(/*wait_compaction=*/true));
+    ASSERT_EQ(commit_messages.size(), 1);
+}
+
+TEST_F(KeyValueFileStoreWriteTest,
+       TestPrepareCommitShouldSucceedWhenDefaultCompactRewriterPathEnabled) {
+    ASSERT_OK_AND_ASSIGN(
+        auto file_store_write,
+        CreateSingleStringFileStoreWrite({{"bucket", "1"}}, 
/*with_temp_directory=*/false));
+
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"k1"));
+    ASSERT_OK_AND_ASSIGN(auto commit_messages,
+                         
file_store_write->PrepareCommit(/*wait_compaction=*/true));
+    ASSERT_EQ(commit_messages.size(), 1);
+}
+
+TEST_F(KeyValueFileStoreWriteTest, TestSpillSimple) {
+    auto fields = {arrow::field("f0", arrow::utf8(), /*nullable=*/false)};
+    arrow::Schema typed_schema(fields);
+    ::ArrowSchema schema;
+    ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+    ASSERT_OK(catalog->CreateDatabase("foo", {}, /*ignore_if_exists=*/false));
+    ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema,
+                                   /*partition_keys=*/{}, 
/*primary_keys=*/{"f0"},
+                                   {{Options::BUCKET, "2"},
+                                    {Options::WRITE_BUFFER_SIZE, "64"},
+                                    {Options::WRITE_BUFFER_SPILLABLE, "true"}},
+                                   /*ignore_if_exists=*/false));
+
+    WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"), "test");
+    context_builder.WithTempDirectory(dir->Str());
+
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
+    ASSERT_OK_AND_ASSIGN(auto file_store_write, 
FileStoreWrite::Create(std::move(write_context)));
+    auto key_value_file_store_write = 
dynamic_cast<KeyValueFileStoreWrite*>(file_store_write.get());
+    auto get_writer = [&](int32_t bucket) -> 
std::shared_ptr<paimon::BatchWriter> {
+        auto partition_iter = 
key_value_file_store_write->writers_.find(BinaryRow::EmptyRow());
+        if (partition_iter != key_value_file_store_write->writers_.end()) {
+            auto& buckets = partition_iter->second;
+            auto bucket_iter = buckets.find(bucket);
+            if (PAIMON_LIKELY(bucket_iter != buckets.end())) {
+                return bucket_iter->second.writer;
+            }
+        }
+        assert(false);
+        return nullptr;
+    };
+
+    // write bucket 0, not trigger spill
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
std::string(48, 'a')));
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
0);
+    ASSERT_GT(get_writer(0)->GetMemoryUsage(), 0);
+
+    // write bucket 1, spill bucket 0 (pick largest writer)
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/1, 
std::string(32, 'b')));
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
1);
+    ASSERT_EQ(get_writer(0)->GetMemoryUsage(), 0);
+    ASSERT_GT(get_writer(1)->GetMemoryUsage(), 0);
+
+    // prepare commit, clean all spill files and memory buffers
+    ASSERT_OK_AND_ASSIGN(auto commit_messages,
+                         
file_store_write->PrepareCommit(/*wait_compaction=*/true));
+    ASSERT_EQ(commit_messages.size(), 2);
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
0);
+    ASSERT_EQ(get_writer(0)->GetMemoryUsage(), 0);
+    ASSERT_EQ(get_writer(1)->GetMemoryUsage(), 0);
+}
+
+TEST_F(KeyValueFileStoreWriteTest, 
TestSpillDiskQuotaExhaustedFallsBackToFlushDataFile) {
+    auto fields = {arrow::field("f0", arrow::utf8(), /*nullable=*/false)};
+    arrow::Schema typed_schema(fields);
+    ::ArrowSchema schema;
+    ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+    ASSERT_OK(catalog->CreateDatabase("foo", {}, /*ignore_if_exists=*/false));
+    ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema,
+                                   /*partition_keys=*/{}, 
/*primary_keys=*/{"f0"},
+                                   {{Options::BUCKET, "1"},
+                                    {Options::WRITE_BUFFER_SIZE, "1"},
+                                    {Options::WRITE_BUFFER_SPILLABLE, "true"},
+                                    
{Options::WRITE_BUFFER_SPILL_MAX_DISK_SIZE, "1b"}},
+                                   /*ignore_if_exists=*/false));
+    ArrowSchemaRelease(&schema);
+    WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"), "test");
+    context_builder.WithTempDirectory(dir->Str());
+
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
+    ASSERT_OK_AND_ASSIGN(auto file_store_write, 
FileStoreWrite::Create(std::move(write_context)));
+
+    // Disk quota is 1 byte, so spill will exhaust quota immediately and fall 
back to
+    // FlushWriteBuffer (writing data files directly instead of spill temp 
files).
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"alice"));
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
0);
+
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"bob"));
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
0);
+
+    ASSERT_OK_AND_ASSIGN(auto commit_messages,
+                         
file_store_write->PrepareCommit(/*wait_compaction=*/true));
+    ASSERT_EQ(commit_messages.size(), 1);
+
+    // Verify all rows are committed correctly despite disk quota exhaustion.
+    auto* commit_impl = 
dynamic_cast<CommitMessageImpl*>(commit_messages[0].get());
+    ASSERT_NE(commit_impl, nullptr);
+    const auto& new_files = commit_impl->GetNewFilesIncrement().NewFiles();
+    ASSERT_FALSE(new_files.empty());
+
+    int64_t total_row_count = 0;
+    for (const auto& file : new_files) {
+        total_row_count += file->row_count;
+    }
+    ASSERT_EQ(total_row_count, 2);
+}
+
+TEST_F(KeyValueFileStoreWriteTest, 
TestMultiRoundSpillWithSameKeyDeduplication) {
+    auto fields = {arrow::field("f0", arrow::utf8(), /*nullable=*/false)};
+    arrow::Schema typed_schema(fields);
+    ::ArrowSchema schema;
+    ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir->Str(), {}));
+    ASSERT_OK(catalog->CreateDatabase("foo", {}, /*ignore_if_exists=*/false));
+    ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &schema,
+                                   /*partition_keys=*/{}, 
/*primary_keys=*/{"f0"},
+                                   {{Options::BUCKET, "1"},
+                                    {Options::WRITE_BUFFER_SIZE, "1"},
+                                    {Options::WRITE_BUFFER_SPILLABLE, "true"}},
+                                   /*ignore_if_exists=*/false));
+    ArrowSchemaRelease(&schema);
+    WriteContextBuilder context_builder(PathUtil::JoinPath(dir->Str(), 
"foo.db/bar"), "test");
+    context_builder.WithTempDirectory(dir->Str()).WithStreamingMode(true);
+
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
+    ASSERT_OK_AND_ASSIGN(auto file_store_write, 
FileStoreWrite::Create(std::move(write_context)));
+
+    // Round 1: alice, bob, alice (duplicate key) → after dedup: alice + bob = 
2 rows
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"alice"));
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"bob"));
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"alice"));
+
+    ASSERT_OK_AND_ASSIGN(auto commit_messages_1,
+                         
file_store_write->PrepareCommit(/*wait_compaction=*/true, 0));
+    ASSERT_EQ(commit_messages_1.size(), 1);
+    {
+        auto* commit_impl = 
dynamic_cast<CommitMessageImpl*>(commit_messages_1[0].get());
+        ASSERT_NE(commit_impl, nullptr);
+        int64_t total_row_count = 0;
+        for (const auto& file : 
commit_impl->GetNewFilesIncrement().NewFiles()) {
+            total_row_count += file->row_count;
+        }
+        ASSERT_EQ(total_row_count, 2);
+    }
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
0);
+
+    // Round 2: bob, charlie, charlie (duplicate key) → after dedup: bob + 
charlie = 2 rows
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"bob"));
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"charlie"));
+    ASSERT_OK(WriteSingleStringRow(file_store_write.get(), /*bucket=*/0, 
"charlie"));
+
+    ASSERT_OK_AND_ASSIGN(auto commit_messages_2,
+                         
file_store_write->PrepareCommit(/*wait_compaction=*/true, 1));
+    ASSERT_EQ(commit_messages_2.size(), 1);
+    {
+        auto* commit_impl = 
dynamic_cast<CommitMessageImpl*>(commit_messages_2[0].get());
+        ASSERT_NE(commit_impl, nullptr);
+        int64_t total_row_count = 0;
+        for (const auto& file : 
commit_impl->GetNewFilesIncrement().NewFiles()) {
+            total_row_count += file->row_count;
+        }
+        ASSERT_EQ(total_row_count, 2);
+    }
+    ASSERT_EQ(TestHelper::CountChannelFiles(dir->GetFileSystem(), dir->Str()), 
0);
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/core/operation/metrics/compaction_metrics.h 
b/src/paimon/core/operation/metrics/compaction_metrics.h
new file mode 100644
index 0000000..aa48a5e
--- /dev/null
+++ b/src/paimon/core/operation/metrics/compaction_metrics.h
@@ -0,0 +1,284 @@
+/*
+ * 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 <algorithm>
+#include <atomic>
+#include <mutex>
+#include <unordered_map>
+#include <vector>
+
+#include "paimon/common/data/binary_row.h"
+#include "paimon/common/metrics/metrics_impl.h"
+
+namespace paimon {
+/// Metrics to measure a compaction.
+class CompactionMetrics {
+ public:
+    static constexpr int32_t kCompactionTimeWindow = 100;
+
+    static constexpr char MAX_LEVEL0_FILE_COUNT[] = "maxLevel0FileCount";
+    static constexpr char AVG_LEVEL0_FILE_COUNT[] = "avgLevel0FileCount";
+    static constexpr char AVG_COMPACTION_TIME[] = "avgCompactionTime";
+    static constexpr char COMPACTION_COMPLETED_COUNT[] = 
"compactionCompletedCount";
+    static constexpr char COMPACTION_TOTAL_COUNT[] = "compactionTotalCount";
+    static constexpr char COMPACTION_QUEUED_COUNT[] = "compactionQueuedCount";
+    static constexpr char MAX_COMPACTION_INPUT_SIZE[] = 
"maxCompactionInputSize";
+    static constexpr char MAX_COMPACTION_OUTPUT_SIZE[] = 
"maxCompactionOutputSize";
+    static constexpr char AVG_COMPACTION_INPUT_SIZE[] = 
"avgCompactionInputSize";
+    static constexpr char AVG_COMPACTION_OUTPUT_SIZE[] = 
"avgCompactionOutputSize";
+    static constexpr char MAX_TOTAL_FILE_SIZE[] = "maxTotalFileSize";
+    static constexpr char AVG_TOTAL_FILE_SIZE[] = "avgTotalFileSize";
+
+    class Reporter {
+     public:
+        Reporter(CompactionMetrics* metrics, const BinaryRow& partition, 
int32_t bucket)
+            : metrics_(metrics), partition_(partition), bucket_(bucket) {}
+
+        void ReportLevel0FileCount(int64_t count) {
+            level0_file_count_ = count;
+        }
+        void ReportCompactionInputSize(int64_t bytes) {
+            compaction_input_size_ = bytes;
+        }
+        void ReportCompactionOutputSize(int64_t bytes) {
+            compaction_output_size_ = bytes;
+        }
+        void ReportTotalFileSize(int64_t bytes) {
+            total_file_size_ = bytes;
+        }
+        void ReportCompactionTime(int64_t time) {
+            metrics_->ReportCompactionTime(time);
+        }
+
+        void IncreaseCompactionsCompletedCount() {
+            metrics_->IncreaseCompactionsCompletedCount();
+        }
+        void IncreaseCompactionsTotalCount() {
+            metrics_->IncreaseCompactionsTotalCount();
+        }
+        void IncreaseCompactionsQueuedCount() {
+            metrics_->IncreaseCompactionsQueuedCount();
+        }
+        void DecreaseCompactionsQueuedCount() {
+            metrics_->DecreaseCompactionsQueuedCount();
+        }
+        void Unregister() {
+            metrics_->EraseReporter(partition_, bucket_);
+        }
+
+        int64_t Level0FileCount() const {
+            return level0_file_count_;
+        }
+
+        int64_t CompactionInputSize() const {
+            return compaction_input_size_;
+        }
+
+        int64_t CompactionOutputSize() const {
+            return compaction_output_size_;
+        }
+
+        int64_t TotalFileSize() const {
+            return total_file_size_;
+        }
+
+     private:
+        CompactionMetrics* metrics_;
+        BinaryRow partition_;
+        int32_t bucket_;
+
+        // Data fields for metrics.
+        int64_t level0_file_count_ = 0;
+        int64_t compaction_input_size_ = 0;
+        int64_t compaction_output_size_ = 0;
+        int64_t total_file_size_ = 0;
+    };
+
+    std::shared_ptr<Reporter> CreateReporter(const BinaryRow& partition, 
int32_t bucket) {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        std::pair<BinaryRow, int32_t> key(partition, bucket);
+        auto reporter = std::make_shared<Reporter>(this, partition, bucket);
+        reporters_[key] = reporter;
+        return reporter;
+    }
+
+    void EraseReporter(const BinaryRow& partition, int32_t bucket) {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        reporters_.erase(std::pair{partition, bucket});
+    }
+
+    void ReportCompactionTime(int64_t time) {
+        std::lock_guard<std::mutex> lock(compaction_times_mutex_);
+        compaction_times_.push_back(time);
+        if (compaction_times_.size() > kCompactionTimeWindow) {
+            compaction_times_.erase(compaction_times_.begin());
+        }
+    }
+
+    double MaxLevel0FileCount() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t max_val = -1;
+        for (const auto& [_, reporter] : reporters_) {
+            max_val = std::max(max_val, reporter->Level0FileCount());
+        }
+        return static_cast<double>(max_val);
+    }
+
+    double AvgLevel0FileCount() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t sum = 0;
+        size_t n = 0;
+        for (const auto& [_, reporter] : reporters_) {
+            sum += reporter->Level0FileCount();
+            n++;
+        }
+        return n > 0 ? static_cast<double>(sum) / n : -1;
+    }
+
+    double MaxCompactionInputSize() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t max_val = -1;
+        for (const auto& [_, reporter] : reporters_) {
+            max_val = std::max(max_val, reporter->CompactionInputSize());
+        }
+        return static_cast<double>(max_val);
+    }
+
+    double MaxCompactionOutputSize() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t max_val = -1;
+        for (const auto& [_, reporter] : reporters_) {
+            max_val = std::max(max_val, reporter->CompactionOutputSize());
+        }
+        return static_cast<double>(max_val);
+    }
+
+    double AvgCompactionInputSize() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t sum = 0;
+        size_t n = 0;
+        for (const auto& [_, reporter] : reporters_) {
+            sum += reporter->CompactionInputSize();
+            n++;
+        }
+        return n > 0 ? static_cast<double>(sum) / n : -1;
+    }
+
+    double AvgCompactionOutputSize() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t sum = 0;
+        size_t n = 0;
+        for (const auto& [_, reporter] : reporters_) {
+            sum += reporter->CompactionOutputSize();
+            n++;
+        }
+        return n > 0 ? static_cast<double>(sum) / n : -1;
+    }
+
+    double AvgCompactionTime() {
+        std::lock_guard<std::mutex> lock(compaction_times_mutex_);
+        if (compaction_times_.empty()) {
+            return 0.0;
+        }
+        int64_t sum = 0;
+        for (const auto& t : compaction_times_) {
+            sum += t;
+        }
+        return static_cast<double>(sum) / compaction_times_.size();
+    }
+
+    double MaxTotalFileSize() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t max_val = -1;
+        for (const auto& [_, reporter] : reporters_) {
+            max_val = std::max(max_val, reporter->TotalFileSize());
+        }
+        return static_cast<double>(max_val);
+    }
+
+    double AvgTotalFileSize() {
+        std::lock_guard<std::mutex> lock(reporter_mutex_);
+        int64_t sum = 0;
+        size_t n = 0;
+        for (const auto& [_, reporter] : reporters_) {
+            sum += reporter->TotalFileSize();
+            n++;
+        }
+        return n > 0 ? static_cast<double>(sum) / n : -1;
+    }
+
+    void IncreaseCompactionsCompletedCount() {
+        compactions_completed_count_++;
+    }
+
+    void IncreaseCompactionsQueuedCount() {
+        compactions_queued_count_++;
+    }
+
+    void DecreaseCompactionsQueuedCount() {
+        compactions_queued_count_--;
+    }
+
+    void IncreaseCompactionsTotalCount() {
+        compactions_total_count_++;
+    }
+
+    int64_t GetCompactionsCompletedCount() const {
+        return compactions_completed_count_;
+    }
+
+    int64_t GetCompactionsTotalCount() const {
+        return compactions_total_count_;
+    }
+
+    int64_t GetCompactionsQueuedCount() const {
+        return compactions_queued_count_;
+    }
+
+    std::shared_ptr<MetricsImpl> GetMetrics() {
+        auto metrics = std::make_shared<MetricsImpl>();
+        metrics->SetCounter(COMPACTION_COMPLETED_COUNT, 
GetCompactionsCompletedCount());
+        metrics->SetCounter(COMPACTION_TOTAL_COUNT, 
GetCompactionsTotalCount());
+        metrics->SetCounter(COMPACTION_QUEUED_COUNT, 
GetCompactionsQueuedCount());
+        metrics->SetGauge(MAX_LEVEL0_FILE_COUNT, MaxLevel0FileCount());
+        metrics->SetGauge(AVG_LEVEL0_FILE_COUNT, AvgLevel0FileCount());
+        metrics->SetGauge(AVG_COMPACTION_TIME, AvgCompactionTime());
+        metrics->SetGauge(MAX_COMPACTION_INPUT_SIZE, MaxCompactionInputSize());
+        metrics->SetGauge(MAX_COMPACTION_OUTPUT_SIZE, 
MaxCompactionOutputSize());
+        metrics->SetGauge(AVG_COMPACTION_INPUT_SIZE, AvgCompactionInputSize());
+        metrics->SetGauge(AVG_COMPACTION_OUTPUT_SIZE, 
AvgCompactionOutputSize());
+        metrics->SetGauge(MAX_TOTAL_FILE_SIZE, MaxTotalFileSize());
+        metrics->SetGauge(AVG_TOTAL_FILE_SIZE, AvgTotalFileSize());
+        return metrics;
+    }
+
+ private:
+    std::unordered_map<std::pair<BinaryRow, int32_t>, 
std::shared_ptr<Reporter>> reporters_;
+    std::mutex reporter_mutex_;
+
+    std::vector<int64_t> compaction_times_;
+    std::mutex compaction_times_mutex_;
+
+    std::atomic<int64_t> compactions_completed_count_ = {0};
+    std::atomic<int64_t> compactions_total_count_ = {0};
+    std::atomic<int64_t> compactions_queued_count_ = {0};
+};
+
+}  // namespace paimon
diff --git a/src/paimon/core/operation/metrics/compaction_metrics_test.cpp 
b/src/paimon/core/operation/metrics/compaction_metrics_test.cpp
new file mode 100644
index 0000000..56919dd
--- /dev/null
+++ b/src/paimon/core/operation/metrics/compaction_metrics_test.cpp
@@ -0,0 +1,152 @@
+/*
+ * 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/operation/metrics/compaction_metrics.h"
+
+#include <cstdint>
+
+#include "gtest/gtest.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+TEST(CompactionMetricsTest, TestReporterAggregationAndCounters) {
+    CompactionMetrics metrics;
+
+    auto reporter1 = metrics.CreateReporter(BinaryRow::EmptyRow(), 0);
+    auto reporter2 = metrics.CreateReporter(BinaryRow::EmptyRow(), 1);
+
+    reporter1->ReportLevel0FileCount(10);
+    reporter2->ReportLevel0FileCount(4);
+    reporter1->ReportCompactionInputSize(200);
+    reporter2->ReportCompactionInputSize(100);
+    reporter1->ReportCompactionOutputSize(150);
+    reporter2->ReportCompactionOutputSize(90);
+    reporter1->ReportTotalFileSize(500);
+    reporter2->ReportTotalFileSize(300);
+
+    reporter1->ReportCompactionTime(50);
+    reporter2->ReportCompactionTime(150);
+
+    reporter1->IncreaseCompactionsCompletedCount();
+    reporter1->IncreaseCompactionsTotalCount();
+    reporter2->IncreaseCompactionsTotalCount();
+    reporter1->IncreaseCompactionsQueuedCount();
+    reporter2->IncreaseCompactionsQueuedCount();
+    reporter2->DecreaseCompactionsQueuedCount();
+
+    auto snapshot = metrics.GetMetrics();
+
+    ASSERT_OK_AND_ASSIGN(auto completed,
+                         
snapshot->GetCounter(CompactionMetrics::COMPACTION_COMPLETED_COUNT));
+    EXPECT_EQ(1, completed);
+    ASSERT_OK_AND_ASSIGN(auto total,
+                         
snapshot->GetCounter(CompactionMetrics::COMPACTION_TOTAL_COUNT));
+    EXPECT_EQ(2, total);
+    ASSERT_OK_AND_ASSIGN(auto queued,
+                         
snapshot->GetCounter(CompactionMetrics::COMPACTION_QUEUED_COUNT));
+    EXPECT_EQ(1, queued);
+
+    ASSERT_OK_AND_ASSIGN(auto max_l0, 
snapshot->GetGauge(CompactionMetrics::MAX_LEVEL0_FILE_COUNT));
+    EXPECT_DOUBLE_EQ(10.0, max_l0);
+    ASSERT_OK_AND_ASSIGN(auto avg_l0, 
snapshot->GetGauge(CompactionMetrics::AVG_LEVEL0_FILE_COUNT));
+    EXPECT_DOUBLE_EQ(7.0, avg_l0);
+
+    ASSERT_OK_AND_ASSIGN(auto max_input,
+                         
snapshot->GetGauge(CompactionMetrics::MAX_COMPACTION_INPUT_SIZE));
+    EXPECT_DOUBLE_EQ(200.0, max_input);
+    ASSERT_OK_AND_ASSIGN(auto avg_input,
+                         
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_INPUT_SIZE));
+    EXPECT_DOUBLE_EQ(150.0, avg_input);
+
+    ASSERT_OK_AND_ASSIGN(auto max_output,
+                         
snapshot->GetGauge(CompactionMetrics::MAX_COMPACTION_OUTPUT_SIZE));
+    EXPECT_DOUBLE_EQ(150.0, max_output);
+    ASSERT_OK_AND_ASSIGN(auto avg_output,
+                         
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_OUTPUT_SIZE));
+    EXPECT_DOUBLE_EQ(120.0, avg_output);
+
+    ASSERT_OK_AND_ASSIGN(auto max_total,
+                         
snapshot->GetGauge(CompactionMetrics::MAX_TOTAL_FILE_SIZE));
+    EXPECT_DOUBLE_EQ(500.0, max_total);
+    ASSERT_OK_AND_ASSIGN(auto avg_total,
+                         
snapshot->GetGauge(CompactionMetrics::AVG_TOTAL_FILE_SIZE));
+    EXPECT_DOUBLE_EQ(400.0, avg_total);
+
+    ASSERT_OK_AND_ASSIGN(auto avg_time, 
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_TIME));
+    EXPECT_DOUBLE_EQ(100.0, avg_time);
+}
+
+TEST(CompactionMetricsTest, TestUnregisterAndEmptyDefaults) {
+    CompactionMetrics metrics;
+
+    auto reporter = metrics.CreateReporter(BinaryRow::EmptyRow(), 7);
+    reporter->ReportLevel0FileCount(9);
+    reporter->ReportCompactionInputSize(123);
+    reporter->ReportCompactionOutputSize(45);
+    reporter->ReportTotalFileSize(999);
+
+    reporter->Unregister();
+
+    auto snapshot = metrics.GetMetrics();
+
+    ASSERT_OK_AND_ASSIGN(auto max_l0, 
snapshot->GetGauge(CompactionMetrics::MAX_LEVEL0_FILE_COUNT));
+    EXPECT_DOUBLE_EQ(-1.0, max_l0);
+    ASSERT_OK_AND_ASSIGN(auto avg_l0, 
snapshot->GetGauge(CompactionMetrics::AVG_LEVEL0_FILE_COUNT));
+    EXPECT_DOUBLE_EQ(-1.0, avg_l0);
+
+    ASSERT_OK_AND_ASSIGN(auto max_input,
+                         
snapshot->GetGauge(CompactionMetrics::MAX_COMPACTION_INPUT_SIZE));
+    EXPECT_DOUBLE_EQ(-1.0, max_input);
+    ASSERT_OK_AND_ASSIGN(auto avg_input,
+                         
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_INPUT_SIZE));
+    EXPECT_DOUBLE_EQ(-1.0, avg_input);
+
+    ASSERT_OK_AND_ASSIGN(auto max_output,
+                         
snapshot->GetGauge(CompactionMetrics::MAX_COMPACTION_OUTPUT_SIZE));
+    EXPECT_DOUBLE_EQ(-1.0, max_output);
+    ASSERT_OK_AND_ASSIGN(auto avg_output,
+                         
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_OUTPUT_SIZE));
+    EXPECT_DOUBLE_EQ(-1.0, avg_output);
+
+    ASSERT_OK_AND_ASSIGN(auto max_total,
+                         
snapshot->GetGauge(CompactionMetrics::MAX_TOTAL_FILE_SIZE));
+    EXPECT_DOUBLE_EQ(-1.0, max_total);
+    ASSERT_OK_AND_ASSIGN(auto avg_total,
+                         
snapshot->GetGauge(CompactionMetrics::AVG_TOTAL_FILE_SIZE));
+    EXPECT_DOUBLE_EQ(-1.0, avg_total);
+
+    ASSERT_OK_AND_ASSIGN(auto avg_time, 
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_TIME));
+    EXPECT_DOUBLE_EQ(0.0, avg_time);
+}
+
+TEST(CompactionMetricsTest, TestCompactionTimeWindow) {
+    CompactionMetrics metrics;
+
+    for (int64_t t = 1; t <= CompactionMetrics::kCompactionTimeWindow + 10; 
++t) {
+        metrics.ReportCompactionTime(t);
+    }
+
+    auto snapshot = metrics.GetMetrics();
+    ASSERT_OK_AND_ASSIGN(auto avg_time, 
snapshot->GetGauge(CompactionMetrics::AVG_COMPACTION_TIME));
+
+    // Only the last kCompactionTimeWindow values are kept.
+    EXPECT_DOUBLE_EQ(60.5, avg_time);
+}
+
+}  // namespace paimon::test

Reply via email to