Merge pull request #10360 from Icinga/dependency-cycle-detection

Rework dependency cycle detection
This commit is contained in:
Julian Brost 2025-03-12 15:58:44 +01:00 committed by GitHub
commit e6ad2199fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 237 additions and 109 deletions

View File

@ -38,6 +38,7 @@ set(base_SOURCES
filelogger.cpp filelogger.hpp filelogger-ti.hpp
function.cpp function.hpp function-ti.hpp function-script.cpp functionwrapper.hpp
initialize.cpp initialize.hpp
intrusive-ptr.hpp
io-engine.cpp io-engine.hpp
journaldlogger.cpp journaldlogger.hpp journaldlogger-ti.hpp
json.cpp json.hpp json-script.cpp

View File

@ -9,11 +9,13 @@
#include "base/dictionary.hpp"
#include <shared_mutex>
#include <unordered_map>
#include <boost/signals2.hpp>
namespace icinga
{
class ConfigObject;
class ConfigItems;
class ConfigType
{
@ -48,6 +50,13 @@ for (const auto& object : objects) {
int GetObjectCount() const;
/**
* Signal that allows hooking into the config loading process just before ConfigObject::OnAllConfigLoaded() is
* called for a bunch of objects. A vector of pointers to these objects is passed as an argument. All elements
* are of the object type the signal is called on.
*/
boost::signals2::signal<void (const ConfigItems&)> BeforeOnAllConfigLoaded;
private:
typedef std::unordered_map<String, intrusive_ptr<ConfigObject> > ObjectMap;
typedef std::vector<intrusive_ptr<ConfigObject> > ObjectVector;

View File

@ -0,0 +1,22 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#pragma once
#include "base/i2-base.hpp"
#include <memory>
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <boost/version.hpp>
// std::hash is only implemented starting from Boost 1.74. Implement it ourselves for older version to allow using
// boost::intrusive_ptr inside std::unordered_set<> or as the key of std::unordered_map<>.
// https://github.com/boostorg/smart_ptr/commit/5a18ffdc5609a0e64b63e47cb81c4f0847e0c087
#if BOOST_VERSION < 107400
template<class T>
struct std::hash<boost::intrusive_ptr<T>>
{
std::size_t operator()(const boost::intrusive_ptr<T>& ptr) const noexcept
{
return std::hash<T*>{}(ptr.get());
}
};
#endif /* BOOST_VERSION < 107400 */

View File

@ -5,6 +5,7 @@
#include "base/i2-base.hpp"
#include "base/debug.hpp"
#include "base/intrusive-ptr.hpp"
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <atomic>
#include <cstddef>

View File

@ -4,6 +4,7 @@
#define SHARED_H
#include "base/atomic.hpp"
#include "base/intrusive-ptr.hpp"
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <cstdint>
#include <utility>

View File

@ -256,17 +256,6 @@ bool DaemonUtility::LoadConfigFiles(const std::vector<std::string>& configs,
upq.SetName("DaemonUtility::LoadConfigFiles");
bool result = ConfigItem::CommitItems(ascope.GetContext(), upq, newItems);
if (result) {
try {
Dependency::AssertNoCycles();
} catch (...) {
Log(LogCritical, "config")
<< DiagnosticInformation(boost::current_exception(), false);
result = false;
}
}
if (!result) {
ConfigCompilerContext::GetInstance()->CancelObjectsFile();
return false;

View File

@ -499,6 +499,23 @@ bool ConfigItem::CommitNewItems(const ActivationContext::Ptr& context, WorkQueue
auto items (itemsByType.find(type.get()));
if (items != itemsByType.end()) {
auto configType = dynamic_cast<ConfigType*>(type.get());
// Skip the call if no handlers are connected (signal::empty()) or there are no items (vector::empty()).
if (configType && !configType->BeforeOnAllConfigLoaded.empty() && !items->second.empty()) {
// Call the signal in the WorkQueue so that if an exception is thrown, it is caught by the WorkQueue
// and then reported like any other config validation error.
upq.Enqueue([&configType, &items]() {
configType->BeforeOnAllConfigLoaded(ConfigItems(items->second));
});
upq.Join();
if (upq.HasExceptions()) {
return false;
}
}
upq.ParallelFor(items->second, [&notified_items](const ItemPair& ip) {
const ConfigItem::Ptr& item = ip.first;
@ -525,6 +542,10 @@ bool ConfigItem::CommitNewItems(const ActivationContext::Ptr& context, WorkQueue
});
upq.Join();
if (upq.HasExceptions()) {
return false;
}
}
}
@ -534,9 +555,6 @@ bool ConfigItem::CommitNewItems(const ActivationContext::Ptr& context, WorkQueue
<< "Sent OnAllConfigLoaded to " << notified_items << " items of type '" << type->GetName() << "'.";
#endif /* I2_DEBUG */
if (upq.HasExceptions())
return false;
notified_items = 0;
for (auto loadDep : type->GetLoadDependencies()) {
auto items (itemsByType.find(loadDep));

View File

@ -101,6 +101,39 @@ private:
static bool CommitNewItems(const ActivationContext::Ptr& context, WorkQueue& upq, std::vector<ConfigItem::Ptr>& newItems);
};
/**
* Helper class for exposing config items being committed to the ConfigType::BeforeOnAllConfigLoaded callback.
*
* This class wraps a reference to an internal data structure used in ConfigItem::CommitNewItems() and provides
* functions useful for the callbacks without exposing the internals of CommitNewItems().
*/
class ConfigItems
{
explicit ConfigItems(std::vector<std::pair<ConfigItem::Ptr, bool>>& items) : m_Items(items) {}
std::vector<std::pair<ConfigItem::Ptr, bool>>& m_Items;
friend ConfigItem;
public:
/**
* ForEachObject<T>(f) calls f(t) for each object T::Ptr t in vector of underlying config items.
*
* @tparam T ConfigObject type to iterate over
* @tparam F Callback functor type (usually automatically deduced from func)
* @param func Functor accepting T::Ptr as an argument to be called for each object
*/
template<typename T, typename F>
void ForEachObject(F func) const
{
for (const auto& item : m_Items) {
if (typename T::Ptr obj = dynamic_pointer_cast<T>(item.first->GetObject()); obj) {
func(std::move(obj));
}
}
}
};
}
#endif /* CONFIGITEM_H */

View File

@ -9,106 +9,163 @@
#include "base/exception.hpp"
#include <map>
#include <sstream>
#include <unordered_map>
#include <utility>
#include <variant>
using namespace icinga;
REGISTER_TYPE(Dependency);
bool Dependency::m_AssertNoCyclesForIndividualDeps = false;
INITIALIZE_ONCE(&Dependency::StaticInitialize);
struct DependencyCycleNode
void Dependency::StaticInitialize()
{
bool Visited = false;
bool OnStack = false;
};
struct DependencyStackFrame
{
ConfigObject::Ptr Node;
bool Implicit;
inline DependencyStackFrame(ConfigObject::Ptr node, bool implicit = false) : Node(std::move(node)), Implicit(implicit)
{ }
};
struct DependencyCycleGraph
{
std::map<Checkable::Ptr, DependencyCycleNode> Nodes;
std::vector<DependencyStackFrame> Stack;
};
static void AssertNoDependencyCycle(const Checkable::Ptr& checkable, DependencyCycleGraph& graph, bool implicit = false);
static void AssertNoParentDependencyCycle(const Checkable::Ptr& parent, DependencyCycleGraph& graph, bool implicit)
{
if (graph.Nodes[parent].OnStack) {
std::ostringstream oss;
oss << "Dependency cycle:\n";
for (auto& frame : graph.Stack) {
oss << frame.Node->GetReflectionType()->GetName() << " '" << frame.Node->GetName() << "'";
if (frame.Implicit) {
oss << " (implicit)";
}
oss << "\n-> ";
}
oss << parent->GetReflectionType()->GetName() << " '" << parent->GetName() << "'";
if (implicit) {
oss << " (implicit)";
}
BOOST_THROW_EXCEPTION(ScriptError(oss.str()));
}
AssertNoDependencyCycle(parent, graph, implicit);
ConfigType::Get<Dependency>()->BeforeOnAllConfigLoaded.connect(&BeforeOnAllConfigLoadedHandler);
}
static void AssertNoDependencyCycle(const Checkable::Ptr& checkable, DependencyCycleGraph& graph, bool implicit)
/**
* Helper class to search for cycles in the dependency graph.
*
* State is stored inside the class and no synchronization is done,
* hence instances of this class must not be used concurrently.
*/
class DependencyCycleChecker
{
auto& node (graph.Nodes[checkable]);
struct Node
{
bool Visited = false;
bool OnStack = false;
std::vector<Dependency::Ptr> ExtraDependencies;
};
if (!node.Visited) {
node.Visited = true;
node.OnStack = true;
graph.Stack.emplace_back(checkable, implicit);
std::unordered_map<Checkable::Ptr, Node> m_Nodes;
for (auto& dep : checkable->GetDependencies()) {
graph.Stack.emplace_back(dep);
AssertNoParentDependencyCycle(dep->GetParent(), graph, false);
graph.Stack.pop_back();
}
// Stack representing the path currently visited by AssertNoCycle(). Dependency::Ptr represents an edge from its
// child to parent, Service::Ptr represents the implicit dependency of that service to its host.
std::vector<std::variant<Dependency::Ptr, Service::Ptr>> m_Stack;
{
auto service (dynamic_pointer_cast<Service>(checkable));
public:
/**
* Add a dependency to this DependencyCycleChecker that will be considered by AssertNoCycle() in addition to
* dependencies already registered to the checkables. This allows checking if additional dependencies would cause
* a cycle before actually registering them to the checkables.
*
* @param dependency Dependency to additionally consider during the cycle search.
*/
void AddExtraDependency(Dependency::Ptr dependency)
{
auto& node = m_Nodes[dependency->GetChild()];
node.ExtraDependencies.emplace_back(std::move(dependency));
}
if (service) {
AssertNoParentDependencyCycle(service->GetHost(), graph, true);
/**
* Searches the dependency graph for cycles and throws an exception if one is found.
*
* Only the part of the graph that's reachable from the starting node when traversing dependencies towards the
* parents is searched. In order to check that there are no cycles in the whole dependency graph, this method
* has to be called for every checkable. For this, the method can be called on the same DependencyCycleChecker
* instance multiple times, in which case parts of the graph aren't searched twice. However, if the graph structure
* changes, a new DependencyCycleChecker instance must be used.
*
* @param checkable Starting node for the cycle search.
* @throws ScriptError A dependency cycle was found.
*/
void AssertNoCycle(const Checkable::Ptr& checkable)
{
auto& node = m_Nodes[checkable];
if (node.OnStack) {
std::ostringstream s;
s << "Dependency cycle:";
for (const auto& obj : m_Stack) {
Checkable::Ptr child, parent;
Dependency::Ptr dependency;
if (std::holds_alternative<Dependency::Ptr>(obj)) {
dependency = std::get<Dependency::Ptr>(obj);
parent = dependency->GetParent();
child = dependency->GetChild();
} else {
const auto& service = std::get<Service::Ptr>(obj);
parent = service->GetHost();
child = service;
}
auto quoted = [](const String& str) { return std::quoted(str.GetData()); };
s << "\n\t- " << child->GetReflectionType()->GetName() << " " << quoted(child->GetName()) << " depends on ";
if (child == parent) {
s << "itself";
} else {
s << parent->GetReflectionType()->GetName() << " " << quoted(parent->GetName());
}
if (dependency) {
s << " (Dependency " << quoted(dependency->GetShortName()) << " " << dependency->GetDebugInfo() << ")";
} else {
s << " (implicit)";
}
}
BOOST_THROW_EXCEPTION(ScriptError(s.str()));
}
if (node.Visited) {
return;
}
node.Visited = true;
node.OnStack = true;
// Implicit dependency of each service to its host
if (auto service (dynamic_pointer_cast<Service>(checkable)); service) {
m_Stack.emplace_back(service);
AssertNoCycle(service->GetHost());
m_Stack.pop_back();
}
// Explicitly configured dependency objects
for (const auto& dep : checkable->GetDependencies()) {
m_Stack.emplace_back(dep);
AssertNoCycle(dep->GetParent());
m_Stack.pop_back();
}
// Additional dependencies to consider
for (const auto& dep : node.ExtraDependencies) {
m_Stack.emplace_back(dep);
AssertNoCycle(dep->GetParent());
m_Stack.pop_back();
}
graph.Stack.pop_back();
node.OnStack = false;
}
}
};
void Dependency::AssertNoCycles()
/**
* Checks that adding these new dependencies to the configuration does not introduce any cycles.
*
* This is done as an optimization: cycles are checked once for all dependencies in a batch of config objects instead
* of individually per dependency in Dependency::OnAllConfigLoaded(). For runtime updates, this function may still be
* called for single objects.
*
* @param items Config items containing Dependency objects added to the running configuration.
*/
void Dependency::BeforeOnAllConfigLoadedHandler(const ConfigItems& items)
{
DependencyCycleGraph graph;
DependencyCycleChecker checker;
for (auto& host : ConfigType::GetObjectsByType<Host>()) {
AssertNoDependencyCycle(host, graph);
}
// Resolve parent/child names to Checkable::Ptr and temporarily add the edges to the checker.
// The dependencies are later registered to the checkables by Dependency::OnAllConfigLoaded().
items.ForEachObject<Dependency>([&checker](Dependency::Ptr dependency) {
dependency->InitChildParentReferences();
checker.AddExtraDependency(std::move(dependency));
});
for (auto& service : ConfigType::GetObjectsByType<Service>()) {
AssertNoDependencyCycle(service, graph);
}
m_AssertNoCyclesForIndividualDeps = true;
// It's sufficient to search for cycles starting from newly added dependencies only: if a newly added dependency is
// part of a cycle, that cycle is reachable from both the child and the parent of that dependency. The cycle search
// is started from the parent as a slight optimization as that will traverse fewer edges if there is no cycle.
items.ForEachObject<Dependency>([&checker](const Dependency::Ptr& dependency) {
checker.AssertNoCycle(dependency->GetParent());
});
}
String DependencyNameComposer::MakeName(const String& shortName, const Object::Ptr& context) const
@ -160,10 +217,8 @@ void Dependency::OnConfigLoaded()
SetStateFilter(FilterArrayToInt(GetStates(), Notification::GetStateFilterMap(), defaultFilter));
}
void Dependency::OnAllConfigLoaded()
void Dependency::InitChildParentReferences()
{
ObjectImpl<Dependency>::OnAllConfigLoaded();
Host::Ptr childHost = Host::GetByName(GetChildHostName());
if (childHost) {
@ -187,21 +242,17 @@ void Dependency::OnAllConfigLoaded()
if (!m_Parent)
BOOST_THROW_EXCEPTION(ScriptError("Dependency '" + GetName() + "' references a parent host/service which doesn't exist.", GetDebugInfo()));
}
void Dependency::OnAllConfigLoaded()
{
ObjectImpl<Dependency>::OnAllConfigLoaded();
// InitChildParentReferences() has to be called before.
VERIFY(m_Child && m_Parent);
m_Child->AddDependency(this);
m_Parent->AddReverseDependency(this);
if (m_AssertNoCyclesForIndividualDeps) {
DependencyCycleGraph graph;
try {
AssertNoDependencyCycle(m_Parent, graph);
} catch (...) {
m_Child->RemoveDependency(this);
m_Parent->RemoveReverseDependency(this);
throw;
}
}
}
void Dependency::Stop(bool runtimeRemoved)

View File

@ -5,6 +5,7 @@
#include "icinga/i2-icinga.hpp"
#include "icinga/dependency-ti.hpp"
#include "config/configitem.hpp"
namespace icinga
{
@ -22,6 +23,8 @@ class Service;
class Dependency final : public ObjectImpl<Dependency>
{
public:
static void StaticInitialize();
DECLARE_OBJECT(Dependency);
DECLARE_OBJECTNAME(Dependency);
@ -36,9 +39,8 @@ public:
static void EvaluateApplyRules(const intrusive_ptr<Host>& host);
static void EvaluateApplyRules(const intrusive_ptr<Service>& service);
static void AssertNoCycles();
/* Note: Only use them for unit test mocks. Prefer OnConfigLoaded(). */
/* Note: Only use them for unit test mocks. Prefer InitChildParentReferences(). */
void SetParent(intrusive_ptr<Checkable> parent);
void SetChild(intrusive_ptr<Checkable> child);
@ -46,15 +48,16 @@ protected:
void OnConfigLoaded() override;
void OnAllConfigLoaded() override;
void Stop(bool runtimeRemoved) override;
void InitChildParentReferences();
private:
Checkable::Ptr m_Parent;
Checkable::Ptr m_Child;
static bool m_AssertNoCyclesForIndividualDeps;
static bool EvaluateApplyRuleInstance(const Checkable::Ptr& checkable, const String& name, ScriptFrame& frame, const ApplyRule& rule, bool skipFilter);
static bool EvaluateApplyRule(const Checkable::Ptr& checkable, const ApplyRule& rule, bool skipFilter = false);
static void BeforeOnAllConfigLoadedHandler(const ConfigItems& items);
};
}