Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions include/paimon/factories/singleton.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

#pragma once

#include <atomic>
#include <memory>
#include <mutex>

#include "paimon/macros.h"
#include "paimon/visibility.h"
Expand All @@ -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);
}
};
Expand All @@ -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
3 changes: 3 additions & 0 deletions src/paimon/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 46 additions & 19 deletions src/paimon/common/factories/io_hook.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,52 +19,79 @@
#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 {

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_);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The race fix itself is welcome — Reset()/Clear() and Try() were genuinely racy before. My concern is where the lock landed: Try() is invoked by CHECK_HOOK on every local-file IO (in local_file.cpp, hook_ is always initialized to IOHook::GetInstance(), so the guard never skips it). The old implementation was lock-free (fetch_add + an atomic pos_ load); the new one takes a std::shared_mutex shared lock on every IO.
Suggested fix: gate the lock behind an "armed" flag. The hook is never armed in production — the default state is exactly what Clear() sets up (pos_ = -1, SILENT), and writers only exist in tests. So we can keep the mutex for correctness on the armed path while restoring a lock-free fast path for the disabled state:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — done in 993b2d5 by gating the synchronized path behind an atomic armed flag:

  • Try() now starts with a lock-free fast path: a single acquire load, returning OK immediately in the disabled state (the production default). The shared_mutex is only taken once armed.
  • Reset() publishes the complete configuration under the mutex and then release-stores armed; armed readers acquire the mutex before touching mode_/pos_, so an observed armed state always implies a complete configuration.
  • Clear() disarms first (still under the mutex) so IO threads drop off the lock as soon as possible.
  • One semantic change worth noting: the disabled fast path no longer increments the counter, so IOCount() now counts only while armed. All IOCount() consumers are tests, and the header documents the contract.
  • Coverage: new IOHookTest.TestDisabledFastPath pins the disabled behavior (Try always OK, no counting, re-arm/disarm restores it). The existing TestConcurrentResetAndTry hammers Reset(INT64_MAX, RETURN_ERROR)/Clear() continuously, so workers keep switching between the fast and armed paths under TSan.

Validation on Kunpeng-920 (aarch64), head 993b2d5: factories suite 10/10 (plus 20× --gtest_shuffle and a --gtest_repeat=5 --gtest_shuffle run); paimon-common-test 1450/1450; paimon-common-sst-file-format-test 32/32; TSan clean over 20 shuffled stress runs of the race tests; pre-commit and git diff --check clean.

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;
};

Expand Down
7 changes: 5 additions & 2 deletions src/paimon/common/factories/io_hook.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
81 changes: 81 additions & 0 deletions src/paimon/common/factories/io_hook_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
18 changes: 3 additions & 15 deletions src/paimon/common/factories/singleton.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand Down
Loading
Loading