8000 Rework dependency cycle detection by julianbrost · Pull Request #10360 · Icinga/icinga2 · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Rework dependency cycle detection #10360

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 12, 2025
Merged

Conversation

julianbrost
Copy link
Contributor
@julianbrost julianbrost commented Mar 5, 2025

With #10290, registering a dependency to a checkable will trigger further events inside Icinga DB, so fully registering it, then checking for cycles and unregistering it if there's actually a cycle isn't really the best idea. This PR provides changes that allow to check for cycles before actually fully registering the dependencies and gives the dependency cycle check code a general makeover. The changes include:

  • Grouping of related structs and previously static functions in a new DependencyCycleChecker helper class for a bit more structure.
  • Adding a new method that allows considering extra dependencies for the cycle check that are not yet registered on the individual checkables (DependencyCycleChecker::AddExtraDependency()).
  • Removal of the distinction between config loaded during process startup and runtime updates (m_AssertNoCyclesForIndividualDeps): CommitNewItems() already adds config objects in batches. The cycle check is now performed for each batch in one go using a newly introduced BeforeOnAllConfigLoaded signal.
  • Improved error messages in case of cycles.

The commit messages and code comments contain more details, so please check there for all the details.

In general, expect for the different error messages, there should be no changes in user-facing behavior.

Tests

The following config has no cycles as-is, uncommenting either of the additional blocks introduces a cycle:

object Host "cyclic-host-a" {
	check_command = "dummy"
}

object Service "example-service" {
	host_name = "cyclic-host-a"
	check_command = "dummy"
}

object Host "cyclic-host-b" {
	check_command = "dummy"
}

object Dependency "dep-a" {
	parent_host_name = "cyclic-host-a"
	parent_service_name = "example-service"
	child_host_name = "cyclic-host-b"
}

#object Dependency "dep-b" {
#	parent_host_name = "cyclic-host-b"
#	child_host_name = "cyclic-host-a"
#}

#apply Dependency "dep-b-apply" to Host {
#	parent_host_name = "cyclic-host-b"
#	assign where host.name != "cyclic-host-b"
#}

#object Dependency "dep-self" {
#	parent_host_name = "cyclic-host-a"
#	child_host_name = "cyclic-host-a"
#}

Additionally, the dependency creating the cycle can also be added as a runtime update:

curl -sSku root:icinga -X PUT 'https://localhost:5665/v1/objects/dependencies/cyclic-host-a!dep-b-runtime' \
     --json '{"attrs":{"parent_host_name":"cyclic-host-b"}, "pretty": true}'

Current master branch (5c651e4)

Without cycle

Config validates, daemon starts successfully.

Cycle with object

# icinga2 daemon -C 
[...]
[2025-03-07 09:14:56 +0100] critical/config: Error: Dependency cycle:
Host 'cyclic-host-a'
-> Dependency 'cyclic-host-a!dep-b'
-> Host 'cyclic-host-b'
-> Dependency 'cyclic-host-b!dep-a'
-> Service 'cyclic-host-a!example-service'
-> Host 'cyclic-host-a' (implicit)
[2025-03-07 09:14:56 +0100] critical/cli: Config validation failed. Re-run with 'icinga2 daemon -C' after fixing the config.

Cycle with apply

# icinga2 daemon -C 
[...]
[2025-03-07 09:15:54 +0100] critical/config: Error: Dependency cycle:
Host 'test-sat-a-231'
-> Dependency 'test-sat-a-231!dep-b-apply'
-> Host 'cyclic-host-b'
-> Dependency 'cyclic-host-b!dep-a'
-> Service 'cyclic-host-a!example-service'
-> Host 'cyclic-host-a' (implicit)
-> Dependency 'cyclic-host-a!dep-b-apply'
-> Host 'cyclic-host-b'
[2025-03-07 09:15:54 +0100] critical/cli: Config validation failed. Re-run with 'icinga2 daemon -C' after fixing the config.

Note: I tested this by including the config in my test cluster, so this shows an unrelated host test-sat-a-231 that's also present there. It's part of the path that found the cycle but not actually part of the cycle itself (the path shown has a shape like the Greek letter ρ). The test below doesn't show that extra host, but that's only because the starting nodes for the search are picked differently, there was no change in the code that would prevent such a path from appearing.

Self-loop

# icinga2 daemon -C 
[...]
[2025-03-10 14:09:39 +0100] critical/config: Error: Dependency cycle:
Host 'cyclic-host-a'
-> Dependency 'cyclic-host-a!dep-self'
-> Host 'cyclic-host-a'
[2025-03-10 14:09:39 +0100] critical/cli: Config validation failed. Re-run with 'icinga2 daemon -C' after fixing the config.

Cycle with runtime update

{
    "results": [
        {
            "code": 500,
            "errors": [
                "Error: Dependency cycle:\nHost 'cyclic-host-b'\n-> Dependency 'cyclic-host-b!dep-a'\n-> Service 'cyclic-host-a!example-service'\n-> Host 'cyclic-host-a' (implicit)\n-> Dependency 'cyclic-host-a!dep-b-runtime'\n-> Host 'cyclic-host-b'\n"
            ],
            "status": "Object could not be created."
        }
    ]
}

Pretty error message from the JSON:

Error: Dependency cycle:
Host 'cyclic-host-b'
-> Dependency 'cyclic-host-b!dep-a'
-> Service 'cyclic-host-a!example-service'
-> Host 'cyclic-host-a' (implicit)
-> Dependency 'cyclic-host-a!dep-b-runtime'
-> Host 'cyclic-host-b'

This PR (fce4775)

Without cycle

Config validates, daemon starts successfully.

Cycle with object

# icinga2 daemon -C 
[...]
[2025-03-12 10:36:15 +0100] critical/config: Error: Dependency cycle:
	- Service "cyclic-host-a!example-service" depends on Host "cyclic-host-a" (implicit)
	- Host "cyclic-host-a" depends on Host "cyclic-host-b" (Dependency "dep-b" in /etc/icinga2/zones.d/master/dependencies.conf: 20:1-20:25)
	- Host "cyclic-host-b" depends on Service "cyclic-host-a!example-service" (Dependency "dep-a" in /etc/icinga2/zones.d/master/dependencies.conf: 14:1-14:25)
[2025-03-12 10:36:15 +0100] critical/config: 1 error
[2025-03-12 10:36:15 +0100] critical/cli: Config validation failed. Re-run with 'icinga2 daemon -C' after fixing the config.

Cycle with apply

# icinga2 daemon -C 
[...]
[2025-03-12 10:36:39 +0100] critical/config: Error: Dependency cycle:
	- Host "cyclic-host-b" depends on Service "cyclic-host-a!example-service" (Dependency "dep-a" in /etc/icinga2/zones.d/master/dependencies.conf: 14:1-14:25)
	- Service "cyclic-host-a!example-service" depends on Host "cyclic-host-a" (implicit)
	- Host "cyclic-host-a" depends on Host "cyclic-host-b" (Dependency "dep-b-apply" in /etc/icinga2/zones.d/master/dependencies.conf: 25:1-25:38)
[2025-03-12 10:36:39 +0100] critical/config: 1 error
[2025-03-12 10:36:39 +0100] critical/cli: Config validation failed. Re-run with 'icinga2 daemon -C' after fixing the config.

Self-loop

# icinga2 daemon -C 
[...]
[2025-03-12 10:37:03 +0100] critical/config: Error: Dependency cycle:
	- Service "cyclic-host-a!example-service" depends on Host "cyclic-host-a" (implicit)
	- Host "cyclic-host-a" depends on itself (Dependency "dep-self" in /etc/icinga2/zones.d/master/dependencies.conf: 30:1-30:28)
[2025-03-12 10:37:03 +0100] critical/config: 1 error
[2025-03-12 10:37:03 +0100] critical/cli: Config validation failed. Re-run with 'icinga2 daemon -C' after fixing the config.

As already noted in the old "cycle with apply" case, additional edges before the actual cycle may be shown both with and without this PR. The checkables are just picked in a different order, so the situations where this happens changed.

Cycle with runtime update

{
    "results": [
        {
            "code": 500,
            "errors": [
                "Error: Dependency cycle:\n\t- Host \"cyclic-host-b\" depends on Service \"cyclic-host-a!example-service\" (Dependency \"dep-a\" in /etc/icinga2/zones.d/master/dependencies.conf: 14:1-14:25)\n\t- Service \"cyclic-host-a!example-service\" depends on Host \"cyclic-host-a\" (implicit)\n\t- Host \"cyclic-host-a\" depends on Host \"cyclic-host-b\" (Dependency \"dep-b-runtime\" in /var/lib/icinga2/api/packages/_api/77e78991-06d4-4589-aa0c-ee60e1d7724e/conf.d/dependencies/cyclic-host-a!dep-b-runtime.conf: 1:0-1:32)\n"
            ],
            "status": "Object could not be created."
        }
    ]
}

Pretty error message from the JSON:

Error: Dependency cycle:
	- Host "cyclic-host-b" depends on Service "cyclic-host-a!example-service" (Dependency "dep-a" in /etc/icinga2/zones.d/master/dependencies.conf: 14:1-14:25)
	- Service "cyclic-host-a!example-service" depends on Host "cyclic-host-a" (implicit)
	- Host "cyclic-host-a" depends on Host "cyclic-host-b" (Dependency "dep-b-runtime" in /var/lib/icinga2/api/packages/_api/77e78991-06d4-4589-aa0c-ee60e1d7724e/conf.d/dependencies/cyclic-host-a!dep-b-runtime.conf: 1:0-1:32)

@julianbrost julianbrost added area/configuration DSL, parser, compiler, error handling core/quality Improve code, libraries, algorithms, inline docs area/runtime Downtimes, comments, dependencies, events labels Mar 5, 2025
@cla-bot cla-bot bot added the cla/signed label Mar 5, 2025
@julianbrost julianbrost force-pushed the dependency-cycle-detection branch from 1660260 to cb80b1d Compare March 6, 2025 09:25
@julianbrost julianbrost changed the title Add ConfigType::BeforeOnAllConfigLoaded signal Rework dependency cycle detection Mar 6, 2025
@julianbrost julianbrost force-pushed the dependency-cycle-detection branch from cb80b1d to 86ba250 Compare March 6, 2025 10:54
@julianbrost
Copy link
Contributor Author

Regarding the choice of using Boost.Signals2 for BeforeOnAllConfigLoaded: that could also have been a virtual member function on the type instances (such that Dependency::TypeInstance->BeforeOnAllConfigLoaded() would simply be a virtual method call) and this pretty much just a poor man's version of that. However, given the current class structure and that the involved classes are generated by the class compiler, I didn't see a way to do it as a method without adding quite a bit of complexity to the class compiler and went with the signal instead. I would be open for better suggestions though.

@julianbrost julianbrost marked this pull request as ready for review March 7, 2025 08:40
@julianbrost julianbrost marked this pull request as draft March 7, 2025 12:51
@julianbrost julianbrost force-pushed the dependency-cycle-detection branch from 86ba250 to 83792af Compare March 7, 2025 12:51
@Al2Klimov Al2Klimov removed their request for review March 10, 2025 09:45
@julianbrost julianbrost force-pushed the dependency-cycle-detection branch 3 times, most recently from c9cdc3c to 6f9628f Compare March 10, 2025 11:34
@julianbrost
Copy link
Contributor Author

With the commit just pushed, everything from #10360 (review) should be addressed.

I'm leaving this in draft state until I've redone and updated the tests in the PR description, but before that, I wan't the Actions to run at least so far that I've good confidence that they'll all succeed. Feel free to already have a look if you'd like.

Regarding #10360 (comment) (printing only the cycle itself if the path that found is ρ-shaped): I'll probably leave it out of the PR for the moment in order to unblock #10290 faster and this is not making it worse than the status-quo. Conceptually, adding it is trivial: only show dependencies after an edge with child == checkable (the latter being the parameter to AssertNoCycle()) was found, though that would have the not so nice property that the trivial implementation would swallow the whole stack if no such edge was found (which should never be the case, otherwise node.> would be a lie, but that could be something that might make the life harder for someone working on that function in the future).

@julianbrost julianbrost marked this pull request as ready for review March 10, 2025 13:27
@julianbrost julianbrost marked this pull request as draft March 12, 2025 09:31
@julianbrost julianbrost force-pushed the dependency-cycle-detection branch from 6f9628f to fce4775 Compare March 12, 2025 09:33
@julianbrost julianbrost marked this pull request as ready for review March 12, 2025 09:39
@julianbrost julianbrost requested a review from yhabteab March 12, 2025 09:39
Allows to hook into the config loading process just before OnAllConfigLoaded()
is called on a bunch of individual config objects. Allows doing some operations
more efficiently at once for all objects.

Intended use: when adding a number of dependencies, it has to be checked
whether this uses any cycles. This can be done more efficiently if all
dependencies are checked at once. So far, this is with a case-distinction for
initially loaded files in DaemonUtility::LoadConfigFiles() and for dependencies
created by runtime updates in Dependency::OnAllConfigLoaded(). The mechanism
added by this commit allows to unify the handling of both cases (done in a
following commit).
Boost only implements it iself starting from version 1.74, but a specialization
of std::hash<> can be added trivially to allow the use of
std::unordered_set<boost::intrusive_ptr<T>> and
std::unordered_map<boost::intrusive_ptr<K>, V>.

Being unable to use such types already came up a few types in the past, often
resulting in the use of raw pointer instead which always involves an additional
"is this safe?"/"could the object go out of scope?" discussion. This commit
simply solves this for the future by simply allowing the use of intrusive_ptr
in unordered containers.
This commit groups a bunch of structs and static functions inside
dependency.cpp into a new DependencyCycleChecker helper class. In the process,
the implementation was changed a bit, the behavior should be unchanged except
for a more user-friendly error message in the exception.
This commit removes a distinction in how dependency objects are checked for
cycles in the resulting graph depending on whether they are part of the
initially loaded configuration during process startup or as part of a runtime
update.

The DependencyCycleChecker helper class is extended with a mechanism that
allows additional dependencies to be considered during the cycle search. This
allows using it to check for cycles before actually registering the
dependencies with the checkables.

The aforementioned case-distinction for initial/runtime-update config is
removed by making use of the newly added BeforeOnAllConfigLoaded signal to
perform the cycle check at once for each batch of dependencies inside
ConfigItem::CommitNewItems() for both cases now. During the initial config
loading, there can be multiple batches of dependencies as objects from apply
rules are created separately, so parts of the dependency graph might be visited
multiple times now, however that is limited to a minimum as only parts of the
graph that are reachable from the newly added dependencies are searched.
@julianbrost julianbrost force-pushed the dependency-cycle-detection branch from fce4775 to 8e7e687 Compare March 12, 2025 11:00
@julianbrost julianbrost requested a review from yhabteab March 12, 2025 11:03
Copy link
Member
@yhabteab yhabteab left a comment

Choose a reason for hiding this comment

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

LFTM!

@julianbrost
Copy link
Contributor Author

@Al2Klimov As I requested a review a few days ago: did you already take a look at this or still want to? Otherwise, I'd 8000 merge this after the CI passed.

* @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
Copy link
Member

Choose a reason for hiding this comment

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

Glad to see that the compiler complains, and not dyncasts, if T doesn't match F.


// 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) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
items.ForEachObject<Dependency>([&checker](Dependency::Ptr dependency) {
items.ForEachObject<Dependency>([&checker](Ptr dependency) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Possible to do, but doesn't help with readability in my opinion.

// 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) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
items.ForEachObject<Dependency>([&checker](const Dependency::Ptr& dependency) {
items.ForEachObject<Dependency>([&checker](const Ptr& dependency) {

@yhabteab yhabteab added this to the 2.15.0 milestone Mar 12, 2025
@julianbrost julianbrost merged commit e6ad219 into master Mar 12, 2025
23 checks passed
@julianbrost julianbrost deleted the dependency-cycle-detection branch March 12, 2025 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/configuration DSL, parser, compiler, error handling area/runtime Downtimes, comments, dependencies, events cla/signed core/quality Improve code, libraries, algorithms, inline docs
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants
0