Skip to content

IcingaDB: Make Redis & DB values consistent #10452

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

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

yhabteab
Copy link
Member

@yhabteab yhabteab commented May 21, 2025

This tries to make some of the values written to Redis and to the Database more consistent. Specifically, previously Icinga DB sent 0 or 1 for the state_type field, and the Icinga DB (Go) daemon mapped them to soft and hard accordingly before writing them to the database. This is not the case anymore, and Icinga DB (C++) now writes soft and hard directly to Redis. This also applies to the comment#entry_type, {user, notification}#types,states and notification#type fields. They all are now serialized exactly how they're going to be written to the database, thus eliminating the need for the Go daemon to do any mappings. Apart from that, the is_acknowledged field is now serialized as true or false as opposed to 1 (normal) or 2 (sticky) previously. From now on that field will be set to true if the checkable has been acknowledged, and the new is_sticky_acknowledgement field will be set to true if the acknowledgement is sticky.

One additional bug that's been fixed by this PR but isn't related to the above topic is that the {user, notification}#types,states bitsets will correctly be refreshed whenever the user or notification types,states attributes are changed at runtime. Previously, the bitsets were only updated at startup, and never refreshed again (see 98dee18).

Required By

fixes #9427

Comment on lines 222 to 248
/**
* Converts the given filter to its Redis value representation.
*
* Within the Icinga 2 code base, if the states and types filter bitsets are set to -1, the filter will match
* on all states/types. However, since sending -1 to Redis would crash the Icinga DB daemon, as both fields are
* of type Go's uint8/uint16, so the primary purpose of this function is to make sure that no negative values are
* passed to Redis.
*
* @param filter The filter to convert.
* @param isStatesFilter Whether the given filter is a states filter or a types filter.
*/
int IcingaDB::StatesOrTypesFilterToRedisValue(int filter, bool isStatesFilter)
{
if (filter >= 0) {
return filter;
}

if (isStatesFilter) {
return StateFilterOK | StateFilterWarning | StateFilterCritical | StateFilterUnknown | StateFilterUp | StateFilterDown;
}

return NotificationDowntimeStart | NotificationDowntimeEnd | NotificationDowntimeRemoved | NotificationCustom |
NotificationAcknowledgement | NotificationProblem | NotificationRecovery | NotificationFlappingStart | NotificationFlappingEnd;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I find this function odd for two reasons:

  1. It repeats all the StateFilter* and Notification* values.
  2. The "what does it actually do" bool argument makes it's uses somewhat awkward:
    attributes->Set("states", StatesOrTypesFilterToRedisValue(user->GetStateFilter(), true));
    attributes->Set("types", StatesOrTypesFilterToRedisValue(user->GetTypeFilter(), false));

    Those would be more readable if this was just two different functions:
    attributes->Set("states", StateFilterToRedisValue(user->GetStateFilter()));
    attributes->Set("types", TypeFilterToRedisValue(user->GetTypeFilter()));

Where does that -1 actually come from? That's what you get from using ~0 (i.e. all bits set to 1) as an integer as it's used here for example:

SetTypeFilter(FilterArrayToInt(GetTypes(), Notification::GetTypeFilterMap(), ~0));
SetStateFilter(FilterArrayToInt(GetStates(), Notification::GetStateFilterMap(), ~0));

Given that FilterArrayToInt() already receives all valid values in filterMap, it could also restrict its return value to an integer with only the bits set that are actually used.

Copy link
Member Author

Choose a reason for hiding this comment

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

Given that FilterArrayToInt() already receives all valid values in filterMap, it could also restrict its return value to an integer with only the bits set that are actually used.

That would break this validation! Yes, it's weird, but the -1 value has currently two different interpretations.

int filter = FilterArrayToInt(lvalue(), Notification::GetStateFilterMap(), 0);
if (filter == -1 || (filter & ~(StateFilterUp | StateFilterDown | StateFilterOK | StateFilterWarning | StateFilterCritical | StateFilterUnknown)) != 0)
BOOST_THROW_EXCEPTION(ValidationError(this, { "states" }, "State filter is invalid."));

Copy link
Member Author

Choose a reason for hiding this comment

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

Or were you referring with your comment to its callers to provide all bits of the desired filters instead of ~0 and leave the implementation of FilterArrayToInt() unchanged?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh wow, that's a great example why C-style error reporting sucks.

I was thinking of changing FilterArrayToInt() but didn't look into it for long enough that it also returns -1 to report errors. However, that shouldn't be a problem for what I had in mind: in the if (!typeFilters), just unset all bits from defaultValue that are not set in any filterMap value.

Untested diff of what I had in mind
diff --git a/lib/icinga/customvarobject.cpp b/lib/icinga/customvarobject.cpp
index 0edc369d9..d50b8f49d 100644
--- a/lib/icinga/customvarobject.cpp
+++ b/lib/icinga/customvarobject.cpp
@@ -19,12 +19,16 @@ void CustomVarObject::ValidateVars(const Lazy<Dictionary::Ptr>& lvalue, const Va
 
 int icinga::FilterArrayToInt(const Array::Ptr& typeFilters, const std::map<String, int>& filterMap, int defaultValue)
 {
-       int resultTypeFilter;
+       int resultTypeFilter = 0;
 
-       if (!typeFilters)
-               return defaultValue;
-
-       resultTypeFilter = 0;
+       if (!typeFilters) {
+               for (const auto & [name, value] : filterMap) {
+                       if (defaultValue & value) {
+                               resultTypeFilter |= value;
+                       }
+               }
+               return resultTypeFilter;
+       }
 
        ObjectLock olock(typeFilters);
        for (const Value& typeFilter : typeFilters) {

When additionally performing some sanitization in if (typeFilter.IsNumber()), that function would then return -1 to report errors (that error reporting mechanism could be improved though) or a non-negative value where only valid bits are set (valid bits in this case is defined as all bits that are set in any filterMap value).

Given that FilterArrayToInt() seems to be called only with defaultValue = 0 or defaultValue = ~0, there could be room for some simplification by just giving a boolean with the default for all valid bits.

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you really want me to refactor that as part of this PR? There're a bunch of consumers that relies on its current behaviour and changing this now, will also require modifying every and each of its consumers. This will just blow up the PR and of course delay it from being done unnecessarily.

int filter = FilterArrayToInt(lvalue(), DbQuery::GetCategoryFilterMap(), 0);
if (filter != DbCatEverything && (filter & ~(DbCatInvalid | DbCatEverything | DbCatConfig | DbCatState |
DbCatAcknowledgement | DbCatComment | DbCatDowntime | DbCatEventHandler | DbCatExternalCommand |
DbCatFlapping | DbCatLog | DbCatNotification | DbCatProgramStatus | DbCatRetention |
DbCatStateHistory)) != 0)
BOOST_THROW_EXCEPTION(ValidationError(this, { "categories" }, "categories filter is invalid."));

What does this validation even do? Since the desired filters contain DbCatEverything which is set to ~0 => -1, and combining all of them with the bitwise | operator will yield nothing but -1 and flipping the result (-1) with ~ makes it 0 and everything bitwise anded (filter & 0) with 0 will always yield 0. So, I don't know what this validation is used for.

Copy link
Member Author

Choose a reason for hiding this comment

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

$ python3
Python 3.13.3 (main, Apr  8 2025, 13:54:08) [Clang 17.0.0 (clang-1700.0.13.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> all = 0 | ~0 | 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192
>>> all
-1
>>> ~all
0
>>>

Copy link
Member Author

Choose a reason for hiding this comment

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

2. Those would be more readable if this was just two different functions:

   attributes->Set("states", StateFilterToRedisValue(user->GetStateFilter()));
   attributes->Set("types", TypeFilterToRedisValue(user->GetTypeFilter()));

I can just split the existing Icinga DB mapping function into two as suggested here, and everything else remains unchanged (broken).

Copy link
Member Author

Choose a reason for hiding this comment

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

include <itl>

object IdoPgsqlConnection "ido-mysql" {
  categories = [2<<16, 2<<17, 2<<18]
}
---

$ icinga2 daemon -c icinga2.conf -C
[2025-05-30 13:24:54 +0200] information/cli: Icinga application loader (version: v2.14.0-677-g9bdbf8823; debug)
[2025-05-30 13:24:54 +0200] information/cli: Loading configuration file(s).
[2025-05-30 13:24:55 +0200] information/ConfigItem: Committing config item(s).
[2025-05-30 13:24:55 +0200] information/ConfigItem: Instantiated 1 Host.
[2025-05-30 13:24:55 +0200] information/ConfigItem: Instantiated 9 CheckCommands.
[2025-05-30 13:24:55 +0200] information/ConfigItem: Instantiated 1 Downtime.
[2025-05-30 13:24:55 +0200] information/ConfigItem: Instantiated 1 IcingaApplication.
[2025-05-30 13:24:55 +0200] information/ConfigItem: Instantiated 1 IdoPgsqlConnection.
[2025-05-30 13:24:55 +0200] information/cli: Finished validating the configuration file(s).

Copy link
Contributor

Choose a reason for hiding this comment

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

There're a bunch of consumers that relies on its current behaviour

What behavior exactly? Or do you have an example of what would break?

What does this validation even do?

Nothing useful, it's basically dead code at the moment. I believe someone just copied all enum values in there without considering that some of them are special. I don't think DbCatInvalid should actually be in there either.

$ python3
[...]

FYI: you could have verified this more directly just using a static_assert() in the C++ code.

I can just split the existing Icinga DB mapping function into two as suggested here, and everything else remains unchanged

That's also an option, in that case, I'd consider adding a constant similar to DbCatEverything, however defined as only the valid bits, not ~0 to avoid repeating the list of enum values in another place (it's unlikely that we'll add new values, but I think it's still nicer having it in a single place).

Comment on lines 102 to 110
// Since the types and states attributes are user configurable and allowed to change at runtime, we need to
// hook into the OnTypesChanged and OnStatesChanged signals to update the actual filter bitsets whenever these
// attributes change. Otherwise, the filter bitsets would be stale and not reflect their current state.
OnTypesChanged.connect([](const Notification::Ptr& notification, const MessageOrigin::Ptr&) {
notification->SetTypeFilter(FilterArrayToInt(notification->GetTypes(), GetTypeFilterMap(), ~0));
});
OnStatesChanged.connect([](const Notification::Ptr& notification, const MessageOrigin::Ptr&) {
notification->SetStateFilter(FilterArrayToInt(notification->GetStates(), GetStateFilterMap(), ~0));
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't this simply be done as part of the SetTypes() and SetStates() implementation? I don't see why this should need the indirection through these signals.

Copy link
Member Author

Choose a reason for hiding this comment

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

Can't this simply be done as part of the SetTypes() and SetStates() implementation?

Of course, it can be implemented that way but simply isn't the word that would describe it. I just found it easier to use their existing signals instead of having to mess around with the class compiler (making the setters virtual, so that they can be overridden in the User and Notification class accordingly.

Copy link
Contributor

Choose a reason for hiding this comment

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

mess around with the class compiler

As in using it or changing something within its implementation?

Copy link
Member Author

Choose a reason for hiding this comment

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

As in using it or changing something within its implementation?

It doesn't matter now, already changed it and marked the two attributes as set_virtual in the dubious {user,notfication}.ti files.

Copy link
Member Author

Choose a reason for hiding this comment

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

As in using it or changing something within its implementation?

It doesn't matter now, already changed it and marked the two attributes as set_virtual in the dubious {user,notfication}.ti files.

Apparently, this introduced a new problem - the SetTypes() and SetStates() methods are called from within the e.g. ObjectImpl<Notification>::ObjectImpl() constructor, but since they're virtual methods, I've overridden them in the Notification class. However, the invokations from within the constructor are resolved at a compile time instead of the dynamic dispatch at runtime 1, and obviously my overrides get never called on the Notification object.

Footnotes

  1. https://isocpp.org/wiki/faq/strange-inheritance#calling-virtuals-from-ctors

yhabteab added 8 commits June 3, 2025 16:49
So that Icinga DB (Go) daemon doesn't have to make the mappings again.
…trs change

Since the types and states attributes are user configurable and allowed to change at
runtime, we need to hook into the `OnTypesChanged` and `OnStatesChanged` signals to update
the actual filter bitsets whenever these attributes change. Otherwise, the filter bitsets
would be stale and not reflect their current state.
@yhabteab yhabteab force-pushed the streamline-redis-and-db-values branch from 98dee18 to 4ffd3e8 Compare June 3, 2025 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Icinga DB state_type and is_acknowledged keys in redis don't match with what's inserted into the DB
2 participants