Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@

#include <algorithm>
#include <concepts>
#include <format>
#include <map>
#include <string_view>
#include <string>
#include <type_traits>

namespace power_grid_model {
Expand Down Expand Up @@ -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;
Comment on lines +97 to +102

@mgovers mgovers Sep 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please use std::stringstream or similar. std::string is not built for this kind of repeated appending in a loop

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

See how it's done in the TextLogger for reference.

}

template <std::derived_from<Logger> T> T& merge_into(T& destination) const {
if (&destination == this) {
return destination; // nothing to do
Expand All @@ -109,7 +119,11 @@ class MultiThreadedCalculationInfo : public MultiThreadedLoggerImpl<CalculationI
using Report = CalculationInfo::Report;

Report report() const { return get().report(); }
void clear() { get().clear(); }
std::string string_report() const { return get().string_report(); }

protected:
std::string snapshot_locked() const override { return get().string_report(); }
void clear_locked() override { get().clear(); }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A couple of questions:

  • Why is clear_locked protected? It should be accessible by "everyone" now, right? Edit: I see now, CRTP, right?
  • Why not just name it clear directly? The user would directly get this overload unless they explicitly cast the type to get the underlying clear. Also, this avoid potential naming confusion. Edit: Due to CRTP the way to access it is then via clear, as expected. This is just like clear_impl, right?
  • Same questions from above but for TextLogger.

};
} // namespace common::logging

Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It can become a bit obscure how the chain of logger, multithreadedlogger, compositelogger, multithreadedcompositelogger works, specially considering that after come the actual implementations. Can you add a brief description somewhere here explaining the flow a bit, otherwise in the tests.

Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
//
// SPDX-License-Identifier: MPL-2.0

#pragma once

#include "logging.hpp"

#include <algorithm>
#include <memory>
#include <ranges>
#include <string_view>
#include <vector>

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<std::unique_ptr<Logger>> 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this be placed in log_all? I believe it's only relevant there and it may lead to confusion later if we add another member function with log in the "wrong" place and unexpected behaviour triggers.


private:
std::vector<std::unique_ptr<Logger>> children_;

template <typename... Args> void log_all(Args&&... args) {
for (auto& child : children_) {

Check warning on line 34 in power_grid_model_c/power_grid_model/include/power_grid_model/common/composite_logging.hpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a reference-to-const. The current type of "child" is "class std::unique_ptr<class power_grid_model::common::logging::Logger> &".

See more on https://sonarcloud.io/project/issues?id=PowerGridModel_power-grid-model&issues=AaCGlK3QQ8KTdTKRAI-w&open=AaCGlK3QQ8KTdTKRAI-w&pullRequest=1574
child->log(std::forward<Args>(args)...);
Comment on lines +33 to +35

@mgovers mgovers Sep 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you can't forward the same object multiple times. please add a test case that this is not accidentally done. i'd have expected sonar to warn about this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

An additional side note: Since we have some strong conventions about perfect forwarding, let's add a comment here for reference in the future. This cases do lay in one of the valid use cases: we don't care what Args... are nor about the qualification, we just pass them around. Same below.

}
}
};

// 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.
Comment on lines +46 to +47

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since this logger is what will be shared, let's make sure to have these two things explicit in the documentation.

class MultiThreadedCompositeLogger : public MultiThreadedLogger {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm missing some reporting functionality at this stage, since the loggers will be under an abstraction, would reporting work directly via multithreaded? don't you need some overload where users can select from which or all loggers to report?

public:
MultiThreadedCompositeLogger() = default;
explicit MultiThreadedCompositeLogger(std::vector<std::shared_ptr<MultiThreadedLogger>> 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<MultiThreadedLogger> 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this also take in a share_ptr instead to keep it consistent? Probably not, but making sure.

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<Logger> create_child() override {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We probably want this one and below marked as final to avoid user overriding things and messing them up. Or should we leave that up to them?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we want to leave the user have this control? I make for C-API users it makes sense, but Python users and Cpp users (?) shouldn't need to, right?

std::vector<std::unique_ptr<Logger>> child_loggers;
child_loggers.reserve(loggers_.size());
for (auto const& logger : loggers_) {
child_loggers.push_back(logger->create_child());
}
return std::make_unique<CompositeChildLogger>(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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's the difference in behaviour between reset and clear? Do we need both?

for (auto const& logger : loggers_) {
logger->clear();
}
}

[[nodiscard]] bool empty() const { return loggers_.empty(); }

private:
std::vector<std::shared_ptr<MultiThreadedLogger>> loggers_; // owning

template <typename... Args> void log_all(Args&&... args) {
for (auto const& logger : loggers_) {
logger->log(std::forward<Args>(args)...);
}
}
};

} // namespace power_grid_model::common::logging
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "common.hpp"

#include <cstdint>
#include <functional>
#include <memory>
#include <string_view>

Expand Down Expand Up @@ -71,6 +72,13 @@

struct MultiThreadedLogger : public Logger {
virtual std::unique_ptr<Logger> 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<void(std::string_view)> const& callback) const { callback({}); }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this similar to flush() in the TextLogger? Or is their purpose different now?

I see get_output takes the callback as an argument, whereas flush takes the callback via the TextLogger constructor. It feels to me that both are attempting very similar things and only one should remain.

That said, taking it as an argument is a lot more flexible and perhaps aligns best with the C-API. So maybe flush can be removed?

Thoughts?


// Clear accumulated output. Default: no-op.
virtual void clear() {}

Check failure on line 81 in power_grid_model_c/power_grid_model/include/power_grid_model/common/logging.hpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this method is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=PowerGridModel_power-grid-model&issues=AaCGlK7iQ8KTdTKRAI-x&open=AaCGlK7iQ8KTdTKRAI-x&pullRequest=1574
Comment on lines +80 to +81

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is the default no-op? Shouldn't the default just be to clear the underlying logged data?

};

} // namespace common::logging
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,32 @@

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<void(std::string_view)> 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is an extra copy made. Maybe just passing around string_views is fine and converting it once to string at the caller fn point below is sufficient?

Also, since this involves a callback which may throw, it may be a good idea to do Lippincot pattern or similar like in flush for the TextLogger such that we handle exceptions or at least we propagate to one that points towards hey, something is wrong with your callback, can't do anything.

}
fn(snapshot);
Comment on lines +81 to +88

@mgovers mgovers Sep 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

declarative

Suggested change
// 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);
// 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 const snapshot = [this] {
std::lock_guard const lock{mutex_};
return snapshot_locked();
}();
fn(snapshot);

}
void clear() final {
std::lock_guard const lock{mutex_};
clear_locked();
}

protected:
virtual std::string snapshot_locked() const { return {}; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
virtual std::string snapshot_locked() const { return {}; }
// Snapshot implementation. Thread-safety must be handled by the caller
virtual std::string snapshot_thread_unsafe_impl() const { return {}; }

virtual void clear_locked() {}

Check failure on line 97 in power_grid_model_c/power_grid_model/include/power_grid_model/common/multi_threaded_logging.hpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this method is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=PowerGridModel_power-grid-model&issues=AaCGlK8DQ8KTdTKRAI-y&open=AaCGlK8DQ8KTdTKRAI-y&pullRequest=1574

private:
friend class ThreadLogger;

LoggerType log_;
std::mutex mutex_;
mutable std::mutex mutex_;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This was initially strange for me, but it makes sense. See this Herb Sutter article for a nice explanation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why mutable?


void sync(ThreadLogger const& logger) {
assert(&logger != &log_);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,8 +114,12 @@ class MultiThreadedTextLogger : public MultiThreadedLoggerImpl<TextLogger> {
using MultiThreadedLoggerImpl<TextLogger>::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(); }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this be made more efficient if you just get the "raw" data and the turn into a "string" or whatever you may need at the multi threaded logger side? Same for calculation info.

I mention this because I believe this may copy the data twice, which can get expensive easily.

void clear_locked() override { get().clear(); }
};
} // namespace common::logging

Expand Down
1 change: 1 addition & 0 deletions tests/cpp_unit_tests/logging/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
140 changes: 140 additions & 0 deletions tests/cpp_unit_tests/logging/test_composite_logging.cpp

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm missing tests in which a "custom" logger inherits from MultiThreadedCompositeLogger. Also the get_output functionality with a custom callback must be tested (you can get inspiration from the TextLogger tests.

Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
//
// SPDX-License-Identifier: MPL-2.0

#include <power_grid_model/common/composite_logging.hpp>

#include <power_grid_model/common/logging.hpp>
#include <power_grid_model/common/text_logger.hpp>

#include <doctest/doctest.h>

#include <memory>

namespace power_grid_model::common::logging {
namespace {
using LoggerPtr = std::shared_ptr<MultiThreadedTextLogger>;

LoggerPtr make_text_logger() { return std::make_shared<MultiThreadedTextLogger>(); }
} // 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
Loading