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

SteNicholas 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 eafe14e0 fix(build): repair singleton double-checked locking race and 
harden aarch64 portability (#203)
eafe14e0 is described below

commit eafe14e0208c9d6e815cdebf826298c404949fbc
Author: kid <[email protected]>
AuthorDate: Fri Aug 21 17:48:25 2026 +0800

    fix(build): repair singleton double-checked locking race and harden aarch64 
portability (#203)
---
 include/paimon/factories/singleton.h               |  39 +++++-
 src/paimon/CMakeLists.txt                          |   3 +
 src/paimon/common/factories/io_hook.cpp            |  65 ++++++---
 src/paimon/common/factories/io_hook.h              |   7 +-
 src/paimon/common/factories/io_hook_test.cpp       |  81 +++++++++++
 src/paimon/common/factories/singleton.cpp          |  18 +--
 src/paimon/common/factories/singleton_test.cpp     | 108 ++++++++++++++
 src/paimon/common/io/cache/cache_manager.h         |   9 +-
 src/paimon/common/io/cache/cache_manager_test.cpp  | 155 +++++++++++++++++++++
 src/paimon/common/sst/sst_file_writer.cpp          |   5 +-
 src/paimon/common/utils/read_ahead_cache_test.cpp  |   5 +-
 src/paimon/common/utils/saturating_cast.h          |  53 +++++++
 src/paimon/common/utils/saturating_cast_test.cpp   |  74 ++++++++++
 src/paimon/common/utils/serialization_utils.h      |   4 +-
 .../common/utils/serialization_utils_test.cpp      |  48 +++++++
 15 files changed, 629 insertions(+), 45 deletions(-)

diff --git a/include/paimon/factories/singleton.h 
b/include/paimon/factories/singleton.h
index 6e12d456..a3030e5e 100644
--- a/include/paimon/factories/singleton.h
+++ b/include/paimon/factories/singleton.h
@@ -19,7 +19,9 @@
 
 #pragma once
 
+#include <atomic>
 #include <memory>
+#include <mutex>
 
 #include "paimon/macros.h"
 #include "paimon/visibility.h"
@@ -30,9 +32,9 @@ class PAIMON_EXPORT LazyInstantiation {
  protected:
     template <typename T>
     static void Create(T*& ptr) {
-        T* tmp = new T;
-        MEMORY_BARRIER();
-        ptr = tmp;
+        // Publication ordering is handled by the release store in
+        // Singleton<T, InstPolicy>::GetInstance(), so no barrier is needed 
here.
+        ptr = new T;
         static std::shared_ptr<T> destroyer(ptr);
     }
 };
@@ -56,4 +58,35 @@ class PAIMON_EXPORT Singleton : private InstPolicy {
     static T* GetInstance();
 };
 
+template <typename T, typename InstPolicy>
+T* Singleton<T, InstPolicy>::GetInstance() {
+    static std::atomic<T*> ptr{nullptr};
+    static std::mutex mutex;
+    T* p = ptr.load(std::memory_order_acquire);
+    if (PAIMON_UNLIKELY(p == nullptr)) {
+        std::lock_guard<std::mutex> lg(mutex);
+        // Re-check under the mutex with a relaxed load; the mutex already
+        // synchronizes with the creating thread.
+        p = ptr.load(std::memory_order_relaxed);
+        if (p == nullptr) {
+            InstPolicy::Create(p);
+            ptr.store(p, std::memory_order_release);
+        }
+    }
+    return p;
+}
+
+// FactoryCreator and IOHook are instantiated exactly once in singleton.cpp, 
and the
+// extern declarations below suppress implicit instantiation everywhere else. 
The
+// file-format/file-system plugins are separate shared libraries linked with
+// -Bsymbolic, so a per-library copy of GetInstance()'s function-local static 
state
+// would never be interposed: factory registrations would land in a different
+// instance than lookups. Do not replace these with implicit instantiation. 
Types local to a single
+// translation unit (e.g. test-only types) can still instantiate Singleton<T>
+// implicitly because they cannot span library boundaries.
+class FactoryCreator;
+class IOHook;
+extern template class Singleton<FactoryCreator>;
+extern template class Singleton<IOHook>;
+
 }  // namespace paimon
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index bdb11005..adfd968d 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -651,7 +651,9 @@ if(PAIMON_BUILD_TESTS)
                     common/utils/range_helper_test.cpp
                     common/utils/read_ahead_cache_test.cpp
                     common/io/cache/lru_cache_test.cpp
+                    common/io/cache/cache_manager_test.cpp
                     common/utils/byte_range_combiner_test.cpp
+                    common/utils/saturating_cast_test.cpp
                     common/utils/scope_guard_test.cpp
                     common/utils/sensitive_config_utils_test.cpp
                     common/utils/serialization_utils_test.cpp
@@ -682,6 +684,7 @@ if(PAIMON_BUILD_TESTS)
 
     add_paimon_test(common_factories_test
                     SOURCES
+                    common/factories/singleton_test.cpp
                     common/factories/factory_creator_test.cpp
                     common/factories/io_hook_test.cpp
                     STATIC_LINK_LIBS
diff --git a/src/paimon/common/factories/io_hook.cpp 
b/src/paimon/common/factories/io_hook.cpp
index a0576b46..394dc8ea 100644
--- a/src/paimon/common/factories/io_hook.cpp
+++ b/src/paimon/common/factories/io_hook.cpp
@@ -19,9 +19,12 @@
 #include "paimon/common/factories/io_hook.h"
 
 #include <atomic>
+#include <mutex>
+#include <shared_mutex>
 #include <stdexcept>
 
 #include "fmt/format.h"
+#include "paimon/macros.h"
 #include "paimon/status.h"
 
 namespace paimon {
@@ -29,42 +32,66 @@ namespace paimon {
 class IOHook::Impl {
  public:
     Status Try(const std::string& path) {
-        if (io_count_.fetch_add(1) < pos_.load()) {
-            return Status::OK();
-        } else {
-            switch (mode_) {
-                case IOHook::Mode::SILENT:
-                    return Status::OK();
-                case IOHook::Mode::RETURN_ERROR:
-                    return Status::IOError(fmt::format(
-                        "io hook triggered io error at position {}, path {}", 
pos_.load(), path));
-                case IOHook::Mode::THROW_EXCEPTION:
-                    throw std::runtime_error(fmt::format(
-                        "io hook throw io exception at position {}, path {}", 
pos_.load(), path));
-                    return Status::OK();
-                default:
-                    return Status::OK();
-            }
+        // Fast path: the hook is disabled, which is always the case in 
production;
+        // writers (Reset()/Clear()) only exist in tests. This keeps Try() a 
single
+        // atomic load on the IO path instead of a shared_mutex acquisition 
per IO.
+        if (PAIMON_UNLIKELY(armed_.load(std::memory_order_acquire))) {
+            return TryArmed(path);
         }
+        return Status::OK();
     }
 
     inline void Reset(int64_t pos, IOHook::Mode mode) {
+        std::unique_lock<std::shared_mutex> lock(mutex_);
+        mode_ = mode;
         pos_ = pos;
         io_count_ = 0;
-        mode_ = mode;
+        // Arm only after the configuration is complete: TryArmed() reads 
mode_/pos_
+        // under mutex_, which synchronizes with this store, so an observed 
armed state
+        // always implies a complete configuration.
+        armed_.store(true, std::memory_order_release);
     }
 
     int64_t IOCount() const {
+        std::shared_lock<std::shared_mutex> lock(mutex_);
         return io_count_.load();
     }
 
     void Clear() {
-        Reset(-1, IOHook::Mode::SILENT);
+        std::unique_lock<std::shared_mutex> lock(mutex_);
+        // Disarm first so IO threads stop taking the lock as soon as possible.
+        armed_.store(false, std::memory_order_release);
+        mode_ = IOHook::Mode::SILENT;
+        pos_ = -1;
+        io_count_ = 0;
     }
 
  private:
+    Status TryArmed(const std::string& path) {
+        std::shared_lock<std::shared_mutex> lock(mutex_);
+        if (io_count_.fetch_add(1) < pos_) {
+            return Status::OK();
+        } else {
+            switch (mode_) {
+                case IOHook::Mode::SILENT:
+                    return Status::OK();
+                case IOHook::Mode::RETURN_ERROR:
+                    return Status::IOError(fmt::format(
+                        "io hook triggered io error at position {}, path {}", 
pos_, path));
+                case IOHook::Mode::THROW_EXCEPTION:
+                    throw std::runtime_error(fmt::format(
+                        "io hook throw io exception at position {}, path {}", 
pos_, path));
+                    return Status::OK();
+                default:
+                    return Status::OK();
+            }
+        }
+    }
+
+    mutable std::shared_mutex mutex_;
+    std::atomic<bool> armed_ = {false};
     std::atomic<int64_t> io_count_ = {0};
-    std::atomic<int64_t> pos_ = {-1};
+    int64_t pos_ = -1;
     IOHook::Mode mode_ = IOHook::Mode::SILENT;
 };
 
diff --git a/src/paimon/common/factories/io_hook.h 
b/src/paimon/common/factories/io_hook.h
index e0a2f68b..0c66381b 100644
--- a/src/paimon/common/factories/io_hook.h
+++ b/src/paimon/common/factories/io_hook.h
@@ -45,7 +45,8 @@ class PAIMON_EXPORT IOHook : public Singleton<IOHook> {
     };
 
     /// Reset the IO exception position and behavior mode to handle the 
exception.
-    /// IOCount will be reset to 0.
+    /// IOCount will be reset to 0. Arms the hook: Try() switches from its 
lock-free
+    /// disabled fast path to the synchronized armed path.
     ///
     /// @params pos The position where the IO exception occurs.
     /// @params mode The mode of behavior for handling the exception.
@@ -56,12 +57,14 @@ class PAIMON_EXPORT IOHook : public Singleton<IOHook> {
     Status Try(const std::string& path);
 
     /// Get the count of IO operations that have already occurred.
+    /// IOs are only counted while the hook is armed (after Reset(), before 
Clear());
+    /// the disabled fast path does not count.
     ///
     /// @return The number of IO operations executed.
     int64_t IOCount() const;
 
     /// Clear the state of the IOHook, including resetting IO count and
-    /// any stored exception state.
+    /// any stored exception state. Disarms the hook back to the lock-free 
fast path.
     void Clear();
 
  private:
diff --git a/src/paimon/common/factories/io_hook_test.cpp 
b/src/paimon/common/factories/io_hook_test.cpp
index 9bbb1b34..653dc73b 100644
--- a/src/paimon/common/factories/io_hook_test.cpp
+++ b/src/paimon/common/factories/io_hook_test.cpp
@@ -19,9 +19,13 @@
 
 #include "paimon/common/factories/io_hook.h"
 
+#include <atomic>
 #include <stdexcept>
+#include <thread>
+#include <vector>
 
 #include "gtest/gtest.h"
+#include "paimon/status.h"
 #include "paimon/testing/utils/testharness.h"
 
 namespace paimon::test {
@@ -64,4 +68,81 @@ TEST(IOHookTest, TestThrowExceptionMode) {
     hook->Clear();
 }
 
+// The disabled state is the production default: Try() must take the lock-free 
fast
+// path, always return OK, and not count IOs (see IOCount()'s contract). 
Clear() first
+// so the test does not depend on execution order.
+TEST(IOHookTest, TestDisabledFastPath) {
+    auto hook = IOHook::GetInstance();
+    hook->Clear();
+    ASSERT_OK(hook->Try("path"));
+    ASSERT_OK(hook->Try("path"));
+    ASSERT_EQ(0, hook->IOCount());
+
+    // Re-arming and disarming must restore the exact disabled behavior.
+    hook->Reset(0, IOHook::Mode::RETURN_ERROR);
+    ASSERT_NOK(hook->Try("path"));
+    ASSERT_EQ(1, hook->IOCount());
+    hook->Clear();
+    ASSERT_OK(hook->Try("path"));
+    ASSERT_OK(hook->Try("path"));
+    ASSERT_EQ(0, hook->IOCount());
+}
+
+// Regression test for torn IOHook configurations: Reset()/Clear() run on one 
thread
+// while other threads call Try() concurrently. A shared start barrier 
releases all
+// threads together, and the reset thread keeps hammering until every worker 
has
+// finished, so overlap is structural rather than timing-dependent. The 
continuous
+// arm/disarm cycling also keeps workers switching between the disabled fast 
path and
+// the synchronized armed path. Under a ThreadSanitizer build this 
deterministically
+// reports any unsynchronized access; functionally every Try() must return OK.
+TEST(IOHookTest, TestConcurrentResetAndTry) {
+    auto hook = IOHook::GetInstance();
+
+    constexpr int32_t kTryIterations = 50000;
+    constexpr int32_t kNumWorkers = 4;
+
+    std::atomic<bool> start{false};
+    std::atomic<int32_t> workers_done{0};
+    std::atomic<bool> observed_error{false};
+
+    std::thread reset_thread([hook, &start, &workers_done]() {
+        while (!start.load(std::memory_order_acquire)) {
+            std::this_thread::yield();
+        }
+        while (workers_done.load(std::memory_order_relaxed) < kNumWorkers) {
+            hook->Reset(INT64_MAX, IOHook::Mode::RETURN_ERROR);
+            hook->Clear();
+        }
+    });
+
+    std::vector<std::thread> workers;
+    workers.reserve(kNumWorkers);
+    for (int32_t t = 0; t < kNumWorkers; t++) {
+        workers.emplace_back([hook, &start, &workers_done, &observed_error]() {
+            while (!start.load(std::memory_order_acquire)) {
+                std::this_thread::yield();
+            }
+            for (int32_t i = 0; i < kTryIterations; i++) {
+                Status status = hook->Try("concurrent_path");
+                // Reset() arms an unreachable position, while Clear() uses 
SILENT mode,
+                // so both complete states return OK. An IOError exposes a 
torn state.
+                if (!status.ok()) {
+                    observed_error.store(true, std::memory_order_relaxed);
+                }
+            }
+            workers_done.fetch_add(1, std::memory_order_relaxed);
+        });
+    }
+
+    start.store(true, std::memory_order_release);
+    reset_thread.join();
+    for (auto& worker : workers) {
+        worker.join();
+    }
+
+    ASSERT_FALSE(observed_error.load(std::memory_order_relaxed));
+    // Leave the process-wide singleton in its default SILENT state for later 
tests.
+    hook->Clear();
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/common/factories/singleton.cpp 
b/src/paimon/common/factories/singleton.cpp
index a9732259..2a5daa89 100644
--- a/src/paimon/common/factories/singleton.cpp
+++ b/src/paimon/common/factories/singleton.cpp
@@ -19,26 +19,14 @@
 
 #include "paimon/factories/singleton.h"
 
-#include <mutex>
-
 #include "paimon/common/factories/io_hook.h"
 #include "paimon/factories/factory_creator.h"
 
 namespace paimon {
 
-template <typename T, typename InstPolicy>
-T* Singleton<T, InstPolicy>::GetInstance() {
-    static T* ptr;
-    static std::mutex mutex;
-    if (PAIMON_UNLIKELY(!ptr)) {
-        std::lock_guard<std::mutex> lg(mutex);
-        if (!ptr) {
-            InstPolicy::Create(ptr);
-        }
-    }
-    return const_cast<T*>(ptr);
-}
-
+// The single definition point for the two cross-library singletons. See the
+// extern template declarations in singleton.h for why implicit instantiation
+// must stay suppressed for these types.
 template class Singleton<FactoryCreator>;
 template class Singleton<IOHook>;
 
diff --git a/src/paimon/common/factories/singleton_test.cpp 
b/src/paimon/common/factories/singleton_test.cpp
new file mode 100644
index 00000000..efaf921c
--- /dev/null
+++ b/src/paimon/common/factories/singleton_test.cpp
@@ -0,0 +1,108 @@
+/*
+ * 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/factories/singleton.h"
+
+#include <array>
+#include <atomic>
+#include <cstdint>
+#include <thread>
+#include <vector>
+
+#include "gtest/gtest.h"
+
+namespace paimon::test {
+
+namespace {
+
+constexpr int32_t kNumThreads = 32;
+
+// Runs `worker(i)` on kNumThreads threads that are all blocked on a shared 
start
+// flag and released at (nearly) the same time, so that they race on the first
+// Singleton::GetInstance() publication. Joins all threads before returning.
+template <typename Worker>
+void RunStorm(const Worker& worker) {
+    std::atomic<bool> start{false};
+    std::vector<std::thread> threads;
+    threads.reserve(kNumThreads);
+    for (int32_t i = 0; i < kNumThreads; ++i) {
+        threads.emplace_back([&start, &worker, i]() {
+            while (!start.load(std::memory_order_acquire)) {
+                std::this_thread::yield();
+            }
+            worker(i);
+        });
+    }
+    start.store(true, std::memory_order_release);
+    for (auto& thread : threads) {
+        thread.join();
+    }
+}
+
+// Local to this translation unit, so nothing else in the test binary can have
+// instantiated Singleton<FirstPublicationTarget> before this test runs: the 
storm
+// below is guaranteed to race on the *first* publication regardless of link 
order,
+// --gtest_shuffle, or --gtest_filter. GetInstance() is defined in the header, 
so a
+// translation-unit-local type can instantiate it.
+class FirstPublicationTarget {
+ public:
+    FirstPublicationTarget() {
+        for (size_t i = 0; i < payload_.size(); ++i) {
+            payload_[i] = kMagic ^ (i * 0x9E3779B97F4A7C15ULL);
+        }
+    }
+
+    // The publication race let a reader observe the instance pointer before 
the
+    // constructor's stores were visible; this checks every word the ctor 
wrote.
+    bool IsFullyConstructed() const {
+        for (size_t i = 0; i < payload_.size(); ++i) {
+            if (payload_[i] != (kMagic ^ (i * 0x9E3779B97F4A7C15ULL))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+ private:
+    static constexpr uint64_t kMagic = 0xA5A5F00D12345678ULL;
+    std::array<uint64_t, 64> payload_{};
+};
+
+}  // namespace
+
+// Regression gate for the Singleton double-checked-locking publication race: 
32
+// threads race the first GetInstance() of a type local to this file, so the 
gate
+// cannot silently degrade into exercising only the already-published fast 
path.
+TEST(SingletonTest, TestConcurrentFirstPublication) {
+    std::array<FirstPublicationTarget*, kNumThreads> instances{};
+    std::array<bool, kNumThreads> fully_constructed{};
+    RunStorm([&instances, &fully_constructed](int32_t i) {
+        instances[i] = Singleton<FirstPublicationTarget>::GetInstance();
+        fully_constructed[i] = instances[i]->IsFullyConstructed();
+    });
+
+    FirstPublicationTarget* expected = instances[0];
+    ASSERT_NE(expected, nullptr);
+    for (int32_t i = 0; i < kNumThreads; ++i) {
+        ASSERT_EQ(expected, instances[i]);
+        ASSERT_TRUE(fully_constructed[i]);
+    }
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/common/io/cache/cache_manager.h 
b/src/paimon/common/io/cache/cache_manager.h
index f899d46c..6fafefb5 100644
--- a/src/paimon/common/io/cache/cache_manager.h
+++ b/src/paimon/common/io/cache/cache_manager.h
@@ -25,6 +25,7 @@
 #include "paimon/cache/cache.h"
 #include "paimon/common/io/cache/cache_key.h"
 #include "paimon/common/io/cache/lru_cache.h"
+#include "paimon/common/utils/saturating_cast.h"
 #include "paimon/memory/memory_segment.h"
 #include "paimon/result.h"
 
@@ -59,9 +60,13 @@ class PAIMON_EXPORT CacheManager {
     /// @param high_priority_pool_ratio Ratio of capacity reserved for index 
cache [0.0, 1.0).
     ///        If 0, index and data share the same cache.
     CacheManager(int64_t max_memory_bytes, double high_priority_pool_ratio) {
-        auto index_cache_bytes = static_cast<int64_t>(max_memory_bytes * 
high_priority_pool_ratio);
+        // Both factors are config-validated non-negative values, so the 
products are finite;
+        // saturation is only a defense against the undefined double->int64_t 
conversion when
+        // max_memory_bytes is close enough to INT64_MAX that the product 
rounds to 2^63.
+        auto index_cache_bytes =
+            SaturatingDoubleToInteger<int64_t>(max_memory_bytes * 
high_priority_pool_ratio);
         auto data_cache_bytes =
-            static_cast<int64_t>(max_memory_bytes * (1.0 - 
high_priority_pool_ratio));
+            SaturatingDoubleToInteger<int64_t>(max_memory_bytes * (1.0 - 
high_priority_pool_ratio));
         data_cache_ = std::make_shared<LruCache>(data_cache_bytes);
         if (high_priority_pool_ratio == 0.0) {
             index_cache_ = data_cache_;
diff --git a/src/paimon/common/io/cache/cache_manager_test.cpp 
b/src/paimon/common/io/cache/cache_manager_test.cpp
new file mode 100644
index 00000000..4d7c180a
--- /dev/null
+++ b/src/paimon/common/io/cache/cache_manager_test.cpp
@@ -0,0 +1,155 @@
+/*
+ * 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/common/io/cache/cache_manager.h"
+
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <memory>
+#include <vector>
+
+#include "gtest/gtest.h"
+#include "paimon/cache/cache.h"
+#include "paimon/common/io/cache/cache_key.h"
+#include "paimon/common/io/cache/lru_cache.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/memory/memory_segment.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+class CacheManagerTest : public ::testing::Test {
+ public:
+    void SetUp() override {
+        pool_ = GetDefaultPool();
+    }
+
+    std::shared_ptr<CacheKey> MakeKey(int64_t position, bool is_index = false) 
const {
+        return CacheKey::ForPosition("test_file", position, 64, is_index);
+    }
+
+    MemorySegment MakeSegment(int32_t size, char fill_byte) const {
+        auto segment = MemorySegment::AllocateHeapMemory(size, pool_.get());
+        std::memset(segment.MutableData(), fill_byte, size);
+        return segment;
+    }
+
+    std::shared_ptr<LruCache> DataLru(const CacheManager& manager) const {
+        return std::dynamic_pointer_cast<LruCache>(manager.DataCache());
+    }
+
+    std::shared_ptr<LruCache> IndexLru(const CacheManager& manager) const {
+        return std::dynamic_pointer_cast<LruCache>(manager.IndexCache());
+    }
+
+ private:
+    std::shared_ptr<MemoryPool> pool_;
+};
+
+/// Regression test for the double->int64_t conversions in the CacheManager 
constructor:
+/// (double)INT64_MAX rounds to 2^63, which is not representable as int64_t, 
so casting the
+/// product back is undefined behavior (x86 cvttsd2si yields INT64_MIN, 
aarch64 fcvtzs
+/// saturates to INT64_MAX). The conversion must saturate, keeping the 
capacity non-negative.
+TEST_F(CacheManagerTest, TestCapacitySaturatesAtInt64Max) {
+    CacheManager manager(std::numeric_limits<int64_t>::max(), 
/*high_priority_pool_ratio=*/0.0);
+
+    std::shared_ptr<LruCache> data_lru = DataLru(manager);
+    ASSERT_NE(data_lru, nullptr);
+    ASSERT_GE(data_lru->GetMaxWeight(), 0);
+    ASSERT_EQ(data_lru->GetMaxWeight(), std::numeric_limits<int64_t>::max());
+
+    // A ratio of 0.0 means index and data share the same cache.
+    ASSERT_EQ(manager.DataCache(), manager.IndexCache());
+
+    // The saturated capacity accepts entries instead of rejecting every 
insert.
+    std::shared_ptr<CacheKey> key = MakeKey(0);
+    auto reader = [&](const std::shared_ptr<CacheKey>&) -> 
Result<MemorySegment> {
+        return MakeSegment(64, 'A');
+    };
+    ASSERT_OK_AND_ASSIGN(MemorySegment segment, manager.GetPage(key, reader, 
{}));
+    ASSERT_EQ(segment.Size(), 64);
+    ASSERT_EQ(segment.Get(0), 'A');
+}
+
+/// Verifies the exact capacity split between the data and index caches for a 
normal
+/// configuration, plus a Get/Invalidate smoke path through 
CacheManager::GetPage.
+TEST_F(CacheManagerTest, TestNormalSplitAndSmokePath) {
+    CacheManager manager(/*max_memory_bytes=*/1024, 
/*high_priority_pool_ratio=*/0.5);
+
+    std::shared_ptr<LruCache> data_lru = DataLru(manager);
+    std::shared_ptr<LruCache> index_lru = IndexLru(manager);
+    ASSERT_NE(data_lru, nullptr);
+    ASSERT_NE(index_lru, nullptr);
+    ASSERT_EQ(data_lru->GetMaxWeight(), 512);
+    ASSERT_EQ(index_lru->GetMaxWeight(), 512);
+
+    std::shared_ptr<CacheKey> key = MakeKey(0);
+    int32_t reader_calls = 0;
+    auto reader = [&](const std::shared_ptr<CacheKey>&) -> 
Result<MemorySegment> {
+        reader_calls++;
+        return MakeSegment(128, 'B');
+    };
+
+    // The first GetPage is a miss and invokes the reader; the second is a 
cache hit.
+    ASSERT_OK_AND_ASSIGN(MemorySegment first, manager.GetPage(key, reader, 
{}));
+    ASSERT_EQ(first.Get(0), 'B');
+    ASSERT_EQ(reader_calls, 1);
+    ASSERT_OK_AND_ASSIGN(MemorySegment second, manager.GetPage(key, reader, 
{}));
+    ASSERT_EQ(second.Get(0), 'B');
+    ASSERT_EQ(reader_calls, 1);
+
+    // After InvalidPage the reader is invoked again.
+    manager.InvalidPage(key);
+    ASSERT_OK_AND_ASSIGN(MemorySegment third, manager.GetPage(key, reader, 
{}));
+    ASSERT_EQ(third.Get(0), 'B');
+    ASSERT_EQ(reader_calls, 2);
+}
+
+/// Verifies weight-based eviction through GetPage: inserting beyond the data 
cache capacity
+/// evicts the least recently used page and runs its eviction callback.
+TEST_F(CacheManagerTest, TestGetPageEviction) {
+    // The data cache capacity is 512 * (1.0 - 0.5) = 256 bytes.
+    CacheManager manager(/*max_memory_bytes=*/512, 
/*high_priority_pool_ratio=*/0.5);
+
+    std::vector<int64_t> evicted;
+    auto callback_for = [&evicted](int64_t position) -> CacheCallback {
+        return
+            [&evicted, position](const std::shared_ptr<CacheKey>&) { 
evicted.push_back(position); };
+    };
+    auto reader = [&](const std::shared_ptr<CacheKey>&) -> 
Result<MemorySegment> {
+        return MakeSegment(128, 'C');
+    };
+
+    std::shared_ptr<CacheKey> key0 = MakeKey(0);
+    std::shared_ptr<CacheKey> key1 = MakeKey(1);
+    std::shared_ptr<CacheKey> key2 = MakeKey(2);
+    ASSERT_OK_AND_ASSIGN(MemorySegment segment0, manager.GetPage(key0, reader, 
callback_for(0)));
+    ASSERT_EQ(segment0.Get(0), 'C');
+    ASSERT_OK_AND_ASSIGN(MemorySegment segment1, manager.GetPage(key1, reader, 
callback_for(1)));
+    ASSERT_EQ(segment1.Get(0), 'C');
+    ASSERT_TRUE(evicted.empty());
+
+    // 128 + 128 + 128 > 256: inserting key2 evicts key0, the least recently 
used page.
+    ASSERT_OK_AND_ASSIGN(MemorySegment segment2, manager.GetPage(key2, reader, 
callback_for(2)));
+    ASSERT_EQ(segment2.Get(0), 'C');
+    ASSERT_EQ(evicted, std::vector<int64_t>({0}));
+    ASSERT_EQ(manager.DataCache()->Size(), 2);
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/common/sst/sst_file_writer.cpp 
b/src/paimon/common/sst/sst_file_writer.cpp
index ec736e33..f2b3b2de 100644
--- a/src/paimon/common/sst/sst_file_writer.cpp
+++ b/src/paimon/common/sst/sst_file_writer.cpp
@@ -20,6 +20,7 @@
 
 #include "paimon/common/utils/crc32c.h"
 #include "paimon/common/utils/murmurhash_utils.h"
+#include "paimon/common/utils/saturating_cast.h"
 
 namespace paimon {
 SstFileWriter::SstFileWriter(const std::shared_ptr<OutputStream>& out,
@@ -27,8 +28,10 @@ SstFileWriter::SstFileWriter(const 
std::shared_ptr<OutputStream>& out,
                              const std::shared_ptr<BlockCompressionFactory>& 
factory,
                              const std::shared_ptr<MemoryPool>& pool)
     : pool_(pool), out_(out), bloom_filter_(bloom_filter), 
block_size_(block_size) {
+    // block_size * 1.1 exceeds INT32_MAX for block_size above ~1.9GB; 
saturate instead of
+    // relying on the undefined double->int32_t conversion.
     data_block_writer_ =
-        std::make_unique<BlockWriter>(static_cast<int32_t>(block_size * 1.1), 
pool);
+        
std::make_unique<BlockWriter>(SaturatingDoubleToInteger<int32_t>(block_size * 
1.1), pool);
     index_block_writer_ =
         std::make_unique<BlockWriter>(BlockHandle::MAX_ENCODED_LENGTH * 1024, 
pool);
     compression_type_ = factory->GetCompressionType();
diff --git a/src/paimon/common/utils/read_ahead_cache_test.cpp 
b/src/paimon/common/utils/read_ahead_cache_test.cpp
index ab1d0ed3..e7900b7b 100644
--- a/src/paimon/common/utils/read_ahead_cache_test.cpp
+++ b/src/paimon/common/utils/read_ahead_cache_test.cpp
@@ -464,7 +464,8 @@ TEST(TestReadAheadCache, TestPreBufferWindowLimit) {
 
     auto io_hook = paimon::IOHook::GetInstance();
     paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
-    io_hook->Clear();
+    // IOCount() only counts while armed; INT64_MAX never triggers the error 
mode.
+    io_hook->Reset(INT64_MAX, paimon::IOHook::Mode::RETURN_ERROR);
 
     AssertReadEquals({0, 10}, "abcdefghij", &cache);
     // The second range did not fit into the window: only one prefetch IO.
@@ -475,7 +476,7 @@ TEST(TestReadAheadCache, TestPreBufferWindowLimit) {
     ASSERT_EQ(io_hook->IOCount(), 2);
 
     // The range is cached now: re-reading it issues no IO at all.
-    io_hook->Clear();
+    io_hook->Reset(INT64_MAX, paimon::IOHook::Mode::RETURN_ERROR);
     AssertReadEquals({16, 10}, "qrstuvwxyz", &cache);
     ASSERT_EQ(io_hook->IOCount(), 0);
 }
diff --git a/src/paimon/common/utils/saturating_cast.h 
b/src/paimon/common/utils/saturating_cast.h
new file mode 100644
index 00000000..cb9c6039
--- /dev/null
+++ b/src/paimon/common/utils/saturating_cast.h
@@ -0,0 +1,53 @@
+/*
+ * 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 <cmath>
+#include <cstdint>
+#include <limits>
+#include <type_traits>
+
+namespace paimon {
+
+/// Converts a double to int32_t or int64_t with Java's float-to-int or 
float-to-long saturation
+/// policy: NaN converts to 0 and an out-of-range value saturates at the 
bounds of TargetType.
+/// Narrower Java integer conversions require a subsequent narrowing step and 
are not supported by
+/// this helper. A bare static_cast of an unrepresentable double is undefined 
behavior and diverges
+/// across architectures (x86 cvttsd2si yields the "integer indefinite" value, 
while aarch64 fcvtzs
+/// saturates), so doubles that are not provably in range must go through this 
helper.
+template <typename TargetType>
+inline TargetType SaturatingDoubleToInteger(double value) {
+    static_assert(std::is_same_v<TargetType, int32_t> || 
std::is_same_v<TargetType, int64_t>,
+                  "TargetType must be int32_t or int64_t");
+    if (std::isnan(value)) {
+        return 0;
+    }
+    // Comparing against the bounds converted to double keeps the final 
truncation defined:
+    // (double)INT64_MAX rounds up to 2^63, so every value that reaches the 
truncation is
+    // representable in TargetType.
+    if (value >= static_cast<double>(std::numeric_limits<TargetType>::max())) {
+        return std::numeric_limits<TargetType>::max();
+    }
+    if (value <= 
static_cast<double>(std::numeric_limits<TargetType>::lowest())) {
+        return std::numeric_limits<TargetType>::lowest();
+    }
+    return static_cast<TargetType>(value);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/common/utils/saturating_cast_test.cpp 
b/src/paimon/common/utils/saturating_cast_test.cpp
new file mode 100644
index 00000000..cc6b74cf
--- /dev/null
+++ b/src/paimon/common/utils/saturating_cast_test.cpp
@@ -0,0 +1,74 @@
+/*
+ * 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/common/utils/saturating_cast.h"
+
+#include <cstdint>
+#include <limits>
+
+#include "gtest/gtest.h"
+
+namespace paimon::test {
+
+TEST(SaturatingCastTest, TestInt64InRangeTruncatesTowardZero) {
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(0.0), 0);
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(1.9), 1);
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(-1.9), -1);
+    // 2^63 - 1024 is the largest double below 2^63: it stays on the 
truncation path.
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(9223372036854774784.0), 
9223372036854774784LL);
+}
+
+TEST(SaturatingCastTest, TestInt64Saturation) {
+    // (double)INT64_MAX rounds up to 2^63, so the boundary double already 
saturates.
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(
+                  static_cast<double>(std::numeric_limits<int64_t>::max())),
+              std::numeric_limits<int64_t>::max());
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(1e300), 
std::numeric_limits<int64_t>::max());
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(-1e300), 
std::numeric_limits<int64_t>::lowest());
+    
ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(std::numeric_limits<double>::infinity()),
+              std::numeric_limits<int64_t>::max());
+    
ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(-std::numeric_limits<double>::infinity()),
+              std::numeric_limits<int64_t>::lowest());
+    // The lowest bound is exactly representable and must survive as a value.
+    ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(
+                  static_cast<double>(std::numeric_limits<int64_t>::lowest())),
+              std::numeric_limits<int64_t>::lowest());
+}
+
+TEST(SaturatingCastTest, TestInt64NaNBecomesZero) {
+    // Java's (long)Double.NaN == 0.
+    
ASSERT_EQ(SaturatingDoubleToInteger<int64_t>(std::numeric_limits<double>::quiet_NaN()),
 0);
+}
+
+TEST(SaturatingCastTest, TestInt32Path) {
+    // SstFileWriter converts through the int32_t instantiation.
+    ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(42.7), 42);
+    ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(-42.7), -42);
+    // The int32_t bounds are exactly representable as doubles and saturate 
inclusively.
+    ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(2147483647.0),
+              std::numeric_limits<int32_t>::max());
+    ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(2147483648.0),
+              std::numeric_limits<int32_t>::max());
+    ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(-2147483648.0),
+              std::numeric_limits<int32_t>::lowest());
+    ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(-2147483649.0),
+              std::numeric_limits<int32_t>::lowest());
+    
ASSERT_EQ(SaturatingDoubleToInteger<int32_t>(std::numeric_limits<double>::quiet_NaN()),
 0);
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/common/utils/serialization_utils.h 
b/src/paimon/common/utils/serialization_utils.h
index c1e97d03..449766ca 100644
--- a/src/paimon/common/utils/serialization_utils.h
+++ b/src/paimon/common/utils/serialization_utils.h
@@ -78,7 +78,9 @@ class SerializationUtils {
         if (PAIMON_UNLIKELY(bytes->size() < 4)) {
             return Status::Invalid(fmt::format("bytes size {} is less than 4", 
bytes->size()));
         }
-        int32_t arity = *(reinterpret_cast<int32_t*>(bytes->data()));
+        // The buffer is byte-filled, so memcpy avoids the strict-aliasing UB 
of reinterpret_cast.
+        int32_t arity;
+        memcpy(&arity, bytes->data(), sizeof(int32_t));
         if (SystemByteOrder() == ByteOrder::PAIMON_LITTLE_ENDIAN) {
             arity = EndianSwapValue(arity);
         }
diff --git a/src/paimon/common/utils/serialization_utils_test.cpp 
b/src/paimon/common/utils/serialization_utils_test.cpp
index 5e612ff2..56a39a29 100644
--- a/src/paimon/common/utils/serialization_utils_test.cpp
+++ b/src/paimon/common/utils/serialization_utils_test.cpp
@@ -19,7 +19,20 @@
 
 #include "paimon/common/utils/serialization_utils.h"
 
+#include <cstdint>
+#include <memory>
+#include <string>
+
 #include "gtest/gtest.h"
+#include "paimon/common/data/binary_row_writer.h"
+#include "paimon/common/data/binary_string.h"
+#include "paimon/common/io/memory_segment_output_stream.h"
+#include "paimon/common/memory/memory_segment_utils.h"
+#include "paimon/io/byte_array_input_stream.h"
+#include "paimon/io/data_input_stream.h"
+#include "paimon/memory/bytes.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/testing/utils/testharness.h"
 
 namespace paimon::test {
 
@@ -37,4 +50,39 @@ TEST_F(SerializationUtilsTest, TestSerializeBinaryRow) {
     ASSERT_TRUE(bytes);
 }
 
+TEST_F(SerializationUtilsTest, TestDeserializeBinaryRowFromStream) {
+    std::shared_ptr<MemoryPool> memory_pool = GetDefaultPool();
+    // a row with mixed field types, including negative integers and a string
+    BinaryRow row(3);
+    BinaryRowWriter writer(&row, 0, memory_pool.get());
+    writer.WriteInt(0, -123456);
+    writer.WriteLong(1, static_cast<int64_t>(-9000000000));
+    writer.WriteString(2, BinaryString::FromString("hello paimon!", 
memory_pool.get()));
+    writer.Complete();
+
+    // the first 4 bytes on the wire are the big-endian arity (Java-compatible 
format)
+    std::shared_ptr<Bytes> bytes = SerializationUtils::SerializeBinaryRow(row, 
memory_pool.get());
+    ASSERT_TRUE(bytes);
+    ASSERT_GE(bytes->size(), 4);
+    ASSERT_EQ(static_cast<uint8_t>(bytes->data()[0]), 0x00);
+    ASSERT_EQ(static_cast<uint8_t>(bytes->data()[1]), 0x00);
+    ASSERT_EQ(static_cast<uint8_t>(bytes->data()[2]), 0x00);
+    ASSERT_EQ(static_cast<uint8_t>(bytes->data()[3]), 0x03);
+
+    // round-trip through the stream overloads, which fill a fresh byte buffer 
on deserialize
+    MemorySegmentOutputStream 
out(MemorySegmentOutputStream::DEFAULT_SEGMENT_SIZE, memory_pool);
+    ASSERT_OK(SerializationUtils::SerializeBinaryRow(row, &out));
+    auto stream_bytes =
+        MemorySegmentUtils::CopyToBytes(out.Segments(), 0, out.CurrentSize(), 
memory_pool.get());
+    auto input_stream =
+        std::make_shared<ByteArrayInputStream>(stream_bytes->data(), 
stream_bytes->size());
+    DataInputStream data_input_stream(input_stream);
+    ASSERT_OK_AND_ASSIGN(BinaryRow de_row, 
SerializationUtils::DeserializeBinaryRow(
+                                               &data_input_stream, 
memory_pool.get()));
+    ASSERT_EQ(de_row.GetFieldCount(), 3);
+    ASSERT_EQ(de_row.GetInt(0), -123456);
+    ASSERT_EQ(de_row.GetLong(1), static_cast<int64_t>(-9000000000));
+    ASSERT_EQ(de_row.GetString(2).ToString(), "hello paimon!");
+}
+
 }  // namespace paimon::test

Reply via email to