diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/common/calculation_info.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/common/calculation_info.hpp index 021a8ff530..ea9f168c3a 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/common/calculation_info.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/common/calculation_info.hpp @@ -10,8 +10,9 @@ #include #include +#include #include -#include +#include #include namespace power_grid_model { @@ -92,6 +93,15 @@ class CalculationInfo : public Logger { Report report() const { return data_; } void clear() { data_.clear(); } + std::string string_report() const { + std::string result; + for (auto const& [tag, value] : data_) { + // Each line has format: EVENT_CODE\tVALUE + result += std::format("{}\t{}\n", std::to_underlying(tag), value); + } + return result; + } + template T> T& merge_into(T& destination) const { if (&destination == this) { return destination; // nothing to do @@ -109,7 +119,11 @@ class MultiThreadedCalculationInfo : public MultiThreadedLoggerImpl +// +// SPDX-License-Identifier: MPL-2.0 + +#pragma once + +#include "logging.hpp" + +#include +#include +#include +#include +#include + +namespace power_grid_model::common::logging { + +// Owns a list of child loggers (created by MultiThreadedCompositeLogger::create_child) and fans all log calls out to +// each of them. The children are owned by this logger; their lifetimes are tied to this object. +class CompositeChildLogger : public Logger { + public: + explicit CompositeChildLogger(std::vector> children) : children_{std::move(children)} {} + + void log(LogEvent tag) override { log_all(tag); } + void log(LogEvent tag, std::string_view message) override { log_all(tag, message); } + void log(LogEvent tag, double value) override { log_all(tag, value); } + void log(LogEvent tag, Idx value) override { log_all(tag, value); } + + using Logger::log; + + private: + std::vector> children_; + + template void log_all(Args&&... args) { + for (auto& child : children_) { + child->log(std::forward(args)...); + } + } +}; + +// Owning fan-out MultiThreadedLogger. Holds shared ownership of MultiThreadedLogger instances and forwards +// all log calls to each. create_child() creates a CompositeChildLogger that owns one child per registered logger. +// +// Lifetime contract: each registered logger is kept alive by this composite for as long as it remains +// registered (shared ownership), regardless of whether any other owner (e.g. a C API wrapper) has released +// its own reference. This is what makes destroying the wrapper while still registered safe. +// Dedupe: registering the same logger twice is a no-op (idempotent, consistent with logging conventions). +// UB: modifying the logger list while a calculation is in progress. +class MultiThreadedCompositeLogger : public MultiThreadedLogger { + public: + MultiThreadedCompositeLogger() = default; + explicit MultiThreadedCompositeLogger(std::vector> loggers) + : loggers_{std::move(loggers)} {} + + // Add/remove a logger. The object address is unchanged so any existing reference_wrapper + // pointing to this composite remains valid. Do not call while a calculation is in progress. + void add(std::shared_ptr logger) { + if (logger == nullptr) { + return; // defensively ignore null registrations + } + if (std::ranges::any_of(loggers_, [&](auto const& existing) { return existing.get() == logger.get(); })) { + return; // already registered — dedupe silently, consistent with logging API conventions + } + loggers_.push_back(std::move(logger)); + } + void remove(MultiThreadedLogger const* logger) { + if (auto it = std::ranges::find_if(loggers_, [&](auto const& existing) { return existing.get() == logger; }); + it != loggers_.end()) { + loggers_.erase(it); + } + } + void reset() { loggers_.clear(); } + + std::unique_ptr create_child() override { + std::vector> child_loggers; + child_loggers.reserve(loggers_.size()); + for (auto const& logger : loggers_) { + child_loggers.push_back(logger->create_child()); + } + return std::make_unique(std::move(child_loggers)); + } + + void log(LogEvent tag) override { log_all(tag); } + void log(LogEvent tag, std::string_view message) override { log_all(tag, message); } + void log(LogEvent tag, double value) override { log_all(tag, value); } + void log(LogEvent tag, Idx value) override { log_all(tag, value); } + + using MultiThreadedLogger::log; + + // Fan out clear() to every registered logger. + void clear() override { + for (auto const& logger : loggers_) { + logger->clear(); + } + } + + [[nodiscard]] bool empty() const { return loggers_.empty(); } + + private: + std::vector> loggers_; // owning + + template void log_all(Args&&... args) { + for (auto const& logger : loggers_) { + logger->log(std::forward(args)...); + } + } +}; + +} // namespace power_grid_model::common::logging diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/common/logging.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/common/logging.hpp index 15ef2e3265..378627e88d 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/common/logging.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/common/logging.hpp @@ -7,6 +7,7 @@ #include "common.hpp" #include +#include #include #include @@ -71,6 +72,13 @@ class Logger { struct MultiThreadedLogger : public Logger { virtual std::unique_ptr create_child() = 0; + + // The function is called exactly once with a string_view valid only for the duration of the call. + // Default: no op / delivers an empty view + virtual void get_output(std::function const& callback) const { callback({}); } + + // Clear accumulated output. Default: no-op. + virtual void clear() {} }; } // namespace common::logging diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/common/multi_threaded_logging.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/common/multi_threaded_logging.hpp index 9f2b7d27c5..4018041948 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/common/multi_threaded_logging.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/common/multi_threaded_logging.hpp @@ -75,11 +75,32 @@ class MultiThreadedLoggerImpl : public MultiThreadedLogger { using MultiThreadedLogger::log; + // Lock-safe overrides. Marked final so subclasses cannot bypass the lock; override + // snapshot_locked / clear_locked instead to add type-specific behaviour. + void get_output(std::function const& fn) const final { + // Snapshot under the lock, then call fn without the lock so user callbacks + // cannot re-enter logger APIs and deadlock on the non-recursive mutex. + std::string snapshot; + { + std::lock_guard const lock{mutex_}; + snapshot = snapshot_locked(); + } + fn(snapshot); + } + void clear() final { + std::lock_guard const lock{mutex_}; + clear_locked(); + } + + protected: + virtual std::string snapshot_locked() const { return {}; } + virtual void clear_locked() {} + private: friend class ThreadLogger; LoggerType log_; - std::mutex mutex_; + mutable std::mutex mutex_; void sync(ThreadLogger const& logger) { assert(&logger != &log_); diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/common/text_logger.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/common/text_logger.hpp index a4b6afc58b..a0a95bde42 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/common/text_logger.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/common/text_logger.hpp @@ -85,6 +85,7 @@ class TextLogger : public Logger { data_.clear(); // reset error flags } std::string report() const { return data_.str(); } + std::string_view report_view() const { return data_.view(); } void flush() { if (flush_handler_) { // exception swallowing: if the handler throws, we leave the logger in valid state and the caller handles it @@ -113,8 +114,12 @@ class MultiThreadedTextLogger : public MultiThreadedLoggerImpl { using MultiThreadedLoggerImpl::MultiThreadedLoggerImpl; std::string report() const { return get().report(); } - void clear() { get().clear(); } + std::string_view report_view() const { return get().report_view(); } void flush() { get().flush(); } + + protected: + std::string snapshot_locked() const override { return get().report(); } + void clear_locked() override { get().clear(); } }; } // namespace common::logging diff --git a/tests/cpp_unit_tests/logging/CMakeLists.txt b/tests/cpp_unit_tests/logging/CMakeLists.txt index 7a733fd3b9..9320773f5e 100644 --- a/tests/cpp_unit_tests/logging/CMakeLists.txt +++ b/tests/cpp_unit_tests/logging/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable( power_grid_model_unit_tests_logging "../test_entry_point.cpp" "test_calculation_info.cpp" + "test_composite_logging.cpp" "test_timer.cpp" "test_text_logger.cpp" ) diff --git a/tests/cpp_unit_tests/logging/test_composite_logging.cpp b/tests/cpp_unit_tests/logging/test_composite_logging.cpp new file mode 100644 index 0000000000..7b9cb08161 --- /dev/null +++ b/tests/cpp_unit_tests/logging/test_composite_logging.cpp @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#include + +#include +#include + +#include + +#include + +namespace power_grid_model::common::logging { +namespace { +using LoggerPtr = std::shared_ptr; + +LoggerPtr make_text_logger() { return std::make_shared(); } +} // namespace + +TEST_CASE("Test MultiThreadedCompositeLogger") { + MultiThreadedCompositeLogger composite; + + SUBCASE("Empty composite has no output and is empty") { CHECK(composite.empty()); } + + SUBCASE("Adding a null logger is a no-op") { + composite.add(nullptr); + CHECK(composite.empty()); + } + + SUBCASE("Logging fans out to a single registered logger") { + auto logger = make_text_logger(); + composite.add(logger); + CHECK_FALSE(composite.empty()); + + composite.log(LogEvent::total, Idx{1}); + + CHECK(logger->report().find("Tag:0") != std::string::npos); + } + + SUBCASE("Logging fans out to multiple registered loggers") { + auto logger_a = make_text_logger(); + auto logger_b = make_text_logger(); + composite.add(logger_a); + composite.add(logger_b); + + composite.log(LogEvent::total, Idx{1}); + + CHECK_FALSE(logger_a->report().empty()); + CHECK_FALSE(logger_b->report().empty()); + } + + SUBCASE("Registering the same logger twice is idempotent") { + auto logger = make_text_logger(); + composite.add(logger); + composite.add(logger); // second add — silent no-op + + composite.log(LogEvent::total, Idx{1}); + + // Only one entry should be logged, i.e. exactly one occurrence of the tag. + auto const report = logger->report(); + auto const first = report.find("Tag:0"); + CHECK(first != std::string::npos); + CHECK(report.find("Tag:0", first + 1) == std::string::npos); + } + + SUBCASE("Remove detaches a specific logger without affecting others") { + auto logger_a = make_text_logger(); + auto logger_b = make_text_logger(); + composite.add(logger_a); + composite.add(logger_b); + + composite.remove(logger_a.get()); + composite.log(LogEvent::total, Idx{1}); + + CHECK(logger_a->report().empty()); + CHECK_FALSE(logger_b->report().empty()); + } + + SUBCASE("Remove of an unregistered logger is a no-op") { + auto logger = make_text_logger(); + composite.remove(logger.get()); // never added + CHECK(composite.empty()); + } + + SUBCASE("Reset detaches all loggers") { + auto logger_a = make_text_logger(); + auto logger_b = make_text_logger(); + composite.add(logger_a); + composite.add(logger_b); + + composite.reset(); + CHECK(composite.empty()); + + composite.log(LogEvent::total, Idx{1}); + CHECK(logger_a->report().empty()); + CHECK(logger_b->report().empty()); + } + + SUBCASE("clear() fans out to every registered logger") { + auto logger = make_text_logger(); + composite.add(logger); + composite.log(LogEvent::total, Idx{1}); + CHECK_FALSE(logger->report().empty()); + + composite.clear(); + CHECK(logger->report().empty()); + } + + SUBCASE("Registered logger implementation stays alive after the caller drops its own shared_ptr") { + MultiThreadedTextLogger const* raw_logger{}; + { + auto logger = make_text_logger(); + raw_logger = logger.get(); + composite.add(logger); + } // caller's shared_ptr is dropped here; the composite keeps its own shared_ptr alive. + CHECK_FALSE(composite.empty()); + + // The composite still owns the implementation, so logging must not crash and must produce output. + // Observing through raw_logger is not UB: the composite's shared_ptr keeps the object alive. + composite.log(LogEvent::total, Idx{1}); + CHECK_FALSE(raw_logger->report().empty()); + } + + SUBCASE("create_child fans out to a child of every registered logger") { + auto logger_a = make_text_logger(); + auto logger_b = make_text_logger(); + composite.add(logger_a); + composite.add(logger_b); + + { + auto child = composite.create_child(); + child->log(LogEvent::total, Idx{1}); + } // child destroyed here; TextLogger children merge into their parent on destruction + + CHECK_FALSE(logger_a->report().empty()); + CHECK_FALSE(logger_b->report().empty()); + } +} +} // namespace power_grid_model::common::logging