-
Notifications
You must be signed in to change notification settings - Fork 17
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
WIP: Revamp config management #1104
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will continue checking on Monday :)
7954aa8
to
e838f52
Compare
Updated to hardcode Still pretty much a WIP, but the bulk should be done, I need to cleanup, document and test now 😬 |
e838f52
to
3b29594
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Finished at Add support for skipping None configs
src/frequenz/sdk/config/_manager.py
Outdated
async def new_receiver( # pylint: disable=too-many-arguments # noqa: DOC502 | ||
self, | ||
*, | ||
wait_for_first: bool = False, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider
- removing
wait_for_first_timeout
from constructor arguments. - changing
wait_for_first
to `wait_for_first_timeout: timedelta | None = None```` :
If wait_for_first
is None don't wait for it first config, if timedelta(X)- wait for X seconds.
Interface will simplify a little - we will have one config instead of 2.
Unless you see any use case to wait infinitelly for the first config.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After the presentation of this to a wider audience I'm really considering to remove the wait for first completely, I think it adds a lot of complexity only to deal with a very niche an obscure situation where there might be a bug and for some reason we don't receive the first config.
I think we can improve debugability of that situation by adding a timeout to the reading of the config files in the config managing actor instead (if we don't have it yet), which would be probably the only place where this could really hang. Also we might add more logging before and after reading the first config, so if you see a "Waiting for first config" without an immediate "Got the initial config: %s", config, that would mean the config reading somehow got stuck. We can now do this in the ConfigManager itself, so users don't need to remember to add these log lines.
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm even more convinced we should remove this, as if we keep this option, the new_receiver()
method needs to be async
, and if it is async
, it can be used in constructors, which complicates initialization even further.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, lets remove it :) It is not so difficult to write it now (after you simplified other things)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I finally moved it to an utility function. This removes all the complexity from the config manager, and if you need this functionality, you can just do a await wait_for_first()
and that will get the default timeout if you don't pass one explicitly.
# This is tricky, because a str is also a Sequence[str], if we would use only | ||
# Sequence[str], then a regular string would also be accepted and taken as | ||
# a sequence, like "key" -> ["k", "e", "y"]. We should never remove the str from | ||
# the allowed types without changing Sequence[str] to something more specific, | ||
# like list[str] or tuple[str]. | ||
key: str | Sequence[str] | None = None, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you try ConfigParser? : https://docs.python.org/3/library/configparser.html
Then we would simplify require that key should follows toml key structure so:
"key.xyz"
or "logging.`frequenz.boo`"
And remove _get_key
method :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't check if it works with toml files...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought of accepting dot notation, but thought it might be better to keep the flexibility of allowing dots in the keys, which is supported by TOML. Also since in most cases we'll just use "key"
or [key, "sub_key"]
, or even key.append(sub_key)
, as we probably want to do a chain for sub-keys, it sounds like at the end it might be more convenient to use sequence than the dot notation, which would mean something like f"{key}.sub_key"
.
In any case, ConfigParser
is not an option as it uses some weird INI file format, not TOML, and we decided to go with TOML for config files. ConfigParser
is a very old beast, with a non-standard format, so I would rather avoid it for something new.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried simplifying this but Python really sucks in terms of sequences of strings.
-
I tried accepting only
Sequence[str]
but it doesn't work because astr
is also aSequence[str]
, so it will keep accepting both, we'll just interpret astr
as a sequence of characters, which is not what we want. -
I tried accepting only
tuple[str]
, but it sucks that a tuple with only one element needs a trailing comma, which is another common pitfall in Python ("str" == ("str")
). Alsotuple("str") == ("s", "t", "r")
because "str" is aSequence[str]
🙄 -
I tried accepting only
list[str]
but then we can't use an explicit default, as lists are mutable, and we hit just one more common Python pitfall. For example with this function:def f(l: list[str] = ["logging"]) -> None: l.append("this sucks") print(l) f()
Prints:
['logging', 'this sucks']
python -m this
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
I guess the problem is I'm not Dutch.
With this in mind, maybe accepting dot notation with plain strings might be worth considering, but I'm also a bit hesitant about having to parse these strings, and parsing back ticks, my feeling is we could get into another problem and then have subtle bugs if there is an unmatched tick or stuff like that.
So I will keep it as is for now and we can reconsider it in a near future. If we go that route, I think I would just assume some restrictions on valid keys. We already have restrictions when creating the config files,I think only ASCII letters [A-Z]
and digits [0-9]
plus underscore _
and dot .
are accepted. I just didn't want to have this limitation hardcoded in the SDK for now, as maybe some user would want to write their config files manually and not have these restrictions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, lets keep it as it is for now.
Wow such simple thing turns out to be so complicated...
value = config | ||
current_path = [] | ||
for subkey in key: | ||
current_path.append(subkey) | ||
if value is None: | ||
return None | ||
match value.get(subkey): | ||
case None: | ||
return None | ||
case Mapping() as new_value: | ||
value = new_value | ||
case _: | ||
subkey_str = "" | ||
if len(key) > 1: | ||
subkey_str = f" when looking for sub-key {key!r}" | ||
_logger.error( | ||
"Found key %r%s but it's not a mapping, returning None: config=%r", | ||
current_path[0] if len(current_path) == 1 else current_path, | ||
subkey_str, | ||
config, | ||
) | ||
return None | ||
value = new_value | ||
return value | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't test it but this should work the same and is shorter.
subconfig = config
for idx, subkey in enumerate(key):
subconfig = subconfig.get(subkey, default=None)
if subconfig is None:
return None
if not isinstnace(subcofnig, Mapping):
curr_key = '.'.join(key[:idx])
substr = curr_key or f" when looking for sub-key {key!r}"
_logger.error(
"Found key %r%s but it's not a mapping, returning None: config=%r",
current_path[0] if len(current_path) == 1 else current_path,
subkey_str,
config,
)
return None
return subconfig
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Am I right that this is basically the same but removing current_path
and using idx
instead to get it, and removing the match
?
It is indeed shorter but I find the longer version more clear, at least it took me a while to understand the proposed code, I actually like the fact about match
that makes all the possibilities very clear and separated, but I guess this probably depends on how each brain works, so I can totally get if for you your code is more clear. I'm happy to change it if that's the case, but I'm not so keep if it is just shorter (but not clearer).
Maybe using the index to get a slice instead of creating a new list can be a bit more efficient and more or less as readable, so I could change only that too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok , previous is more clear to you, then lets leave it :)
This is to be able to use type guards in with `Receiver.filter` to narrow the received type and the new `WithPrevious` class. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This class instantiates and starts the `ConfigManagingActor` and creates the channel needed to communicate with it. It offers a convenience method to get receivers to receive configuration updates. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This option is useful for when retrieving the configuration for the first time, as users might want to block the program's initialization until the configuration is read. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
When this option is enabled, configurations will be sent to the receiver only if they are different from the previously received configuration. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This is useful for actors to be able to subscribe to a particular key with the actor configuration, avoiding spurious updates and noise in the received configuration. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This commit adds support for validating configurations with a schema. This is useful to ensure the configuration is correct and to receive it as an instance of a dataclass. The `new_receiver` method now accepts a `schema` argument that is used to validate the configuration. If the configuration doesn't pass the validation, it will be ignored and an error will be logged. The schema is expected to be a dataclass, which is used to create a marshmallow schema to validate the configuration. To customize the schema, you can use `marshmallow_dataclass.dataclass` to specify extra metadata. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
Configuration can be nested, and actors could have sub-actors that need their own configuration, so we need to support filtering the configuration by a sequence of keys. For example, if the configuration is `{"key": {"subkey": "value"}}`, and the key is `["key", "subkey"]`, then the receiver will get only `"value"`. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
For every path where we create a new receiver, we now assert we end up with the correct type, as the code proved to be a bit weak, and we sometime ended up with a `Receiver[Any]` without noticing. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This is useful for cases where the the receiver can't react to `None` configurations, either because it is handled externally or because it should just keep the previous configuration. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This global instance can be used as a single point where any actor can obtain a receiver to receive configuration updates. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This means using background service with multiple inheritance, so when calling `super().__init__()` it can properly ignore all the keyword arguments it doesn't use. This also means now `Actor` can be used as a *mixin*, as it doesn't provide its own `__init__()`. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This schema is provided to use as a default, and might be extended in the future to support more commonly used fields that are not provided by marshmallow by default. To use the quantity schema we need to bump the `frequenz-quantities` dependency and add the optional `marshmallow` dependency. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This class is mainly provided as a guideline on how to implement actors that can be reconfigured, so actor authors don't forget to do the basic steps to allow reconfiguration, and to have a common interface and pattern when creating reconfigurable actors. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
The `LoggingConfigUpdatingActor` now inherits also from `Reconfigurable` to ensure a consistent initialization. This also means the actor can now receive `None` as configuration (for example if the configuration is removed), in which case it will go back to the default configuration. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
The `Reconfigurable` mixin will be removed as it seems to be overkill and confusing. This reverts commit 3102990ef2d57c2a804a2c41391a512de13fb189. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This class proved to be more confusing than helpful when presented to a wider audience, it seems to be better to propose ways to implement actors as documentation. This reverts commit 3201348. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
Since `Reconfigurable` was removed we don't need this anymore. The added possibility to use as a mixin doesn't seem to justify the loss of safety of adding arbitrary keyword arguments to the constructor. This reverts commit 5eb64165e8603bb50a5c0eb661de7c5770022cb2. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
It seems to be more clear and easier to test to always pass a config manager explicitly, so we just remove the global instance, which was also a bit confusing when presented. This reverts commit 77295dc. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This feature is adding too much complexity, just to make it slightly easier to debug a very specific case, when a receiver gets stuck without being able to receive any messages at all. We will improve logging instead, so we can still debug this case without adding this complexity to the API. This also allows us to make the `new_receiver` method sync. This reverts commit 29725c4. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This seems to be a very niche feature that adds quite a bit of complexity. Users than need this kind of raw access can just get a receiver from the `config_channel` themselves and do the processing they need. Now the `schema` is required, was renamed to `config_class` for extra clarity and is a positional-only argument. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
This is what most users will need, so we better make it the default. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
3b29594
to
4189066
Compare
I just updated this PR with a LOT of simplifications based on the feedback after presenting it to a wider audience. For now I made all reverting explicit, just in case to have more time and feedback about it. In the final PR I will probably just remove the commits that will not be part of the final diff completely. The main changes are:
I have still pending to make it a background service. |
src/frequenz/sdk/config/_manager.py
Outdated
async def new_receiver( # pylint: disable=too-many-arguments # noqa: DOC502 | ||
self, | ||
*, | ||
wait_for_first: bool = False, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, lets remove it :) It is not so difficult to write it now (after you simplified other things)
src/frequenz/sdk/config/_manager.py
Outdated
if skip_unchanged: | ||
receiver = receiver.filter(WithPrevious(not_equal_with_logging)) | ||
receiver = receiver.filter(WithPrevious(_NotEqualWithLogging(key))) | ||
|
||
if wait_for_first: | ||
async with asyncio.timeout(self.wait_for_first_timeout.total_seconds()): | ||
await receiver.ready() | ||
|
||
return receiver | ||
if key is None: | ||
return receiver | ||
|
||
return receiver.map(lambda config: config.get(key)) | ||
|
||
|
||
def not_equal_with_logging( | ||
old_config: Mapping[str, Any], new_config: Mapping[str, Any] | ||
) -> bool: | ||
"""Return whether the two mappings are not equal, logging if they are the same.""" | ||
if old_config == new_config: | ||
_logger.info("Configuration has not changed, skipping update") | ||
_logger.debug("Old configuration being kept: %r", old_config) | ||
return False | ||
return True | ||
class _NotEqualWithLogging: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should simplify code a lot if you do map.filter
instead of filter.map
:)
In other words you first get the key and then apply all filters.
if key is not None:
receiver = receiver.map(lambda config: config.get(key))
if skip_unchanged:
# Now WithPrevious takes config[key] instead of whole config so it should simplify, too.
receiver = receiver.filter(WithPrevious(_NotEqualWithLogging(key)))
return receiver
class _NotEqualWithLogging:
"""A predicate that returns whether the two mappings are not equal.
If the mappings are equal, a logging message will be emitted indicating that the
configuration has not changed for the specified key.
"""
def __init__(self, key: str | None = None) -> None:
"""Initialize this instance.
Args:
key: The key to use in the logging message.
"""
self._key = key
def __call__(
self, old_config: Mapping[str, Any] | None, new_config: Mapping[str, Any] | None
) -> bool:
"""Return whether the two mappings are not equal, logging if they are the same."""
if new_config != old_config:
key_str = f" for key '{self._key}'" if self._key else ""
_logger.info("Configuration%s has changed, updating", key_str)
_logger.debug("New configuration%s being applied: %r", key_str, new_config)
return True
return False
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The above should also work with other commits. But maybe I am missing something
if key is not None:
receiver = receiver.map(lambda config: _get_key(config, key))
if skip_unchanged:
# _NotEqualWithLogging will simplify
receiver = receiver.filter(WithPrevious(_NotEqualWithLogging(key)))
receiver = receiver.map(
# load_config_with_logging will simplify
lambda config: load_config_with_logging(
config_class, config, base_schema=base_schema, **marshmallow_load_kwargs
)
).filter(_is_valid_or_none)
if skip_none:
receiver = receiver.filter(_is_dataclass)
return receiver
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In other words you first get the key and then apply all filters.
Yes! I can't believe I missed that before, but in the new version I also reversed the oder of map and filter and it made the code much simpler!
class BaseConfigSchema(QuantitySchema): | ||
"""A base schema for configuration classes.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-
Just wondering, why you create BaseConfigSchema?
User can just use QuantitySchema -
BaseConfigSchema is not used anywhere
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Just wondering, why you create BaseConfigSchema?
User can just use QuantitySchema
TL;DR: It is mostly to have a more future-proof API.
Maybe it is over-engineering for now, but my thought was that users might want to inherit from BaseConfigSchema
and add their own custom fields. If in the future we have some other library providing a base schema like QuantitySchema
, we can make BaseConfigSchema
inherit from that too, and the user's code doesn't need to change, it will automatically get the new fields if they are inheriting from BaseConfigSchema
.
If we use QuantitySchema
(so users inherit from that), and then we want to ship a base schema with more fields than QuantitySchema
, the users will either need to update their code to now inherit from BaseConfigSchema
, or they won't get the new fields.
- BaseConfigSchema is not used anywhere
Yeah, draft PR :-P Fixed in the new version.
@@ -34,6 +34,7 @@ def __init__( # pylint: disable=too-many-arguments | |||
*, | |||
auto_start: bool = True, | |||
force_polling: bool = True, | |||
logging_config_key: str | Sequence[str] | None = "logging", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we will always have this enabled.
Maybe to simplify code we could just enable this by default and not make it configurable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this option adds a lot of complexity, it is just a couple of extra if
s, and gives a bit of extra flexibility to external users that might not want to allow reconfiguring the logging. And if you just want to disable that, it means you cannot use the config manager and miss on a lot of other useful features. Is not like making the new_receiver()
method less flexible, where we have a escape hatch (just do manager.config_channel.new_receiver()
to create your own), here you can't use the config manager at all if you want to customize only that one small part.
I don't have a strong opinion though, given the trade-offs I prefer for it to stay but I'm also open to remove it (it should be pretty easy to add it back if needed).
if "unknown" not in marshmallow_load_kwargs: | ||
marshmallow_load_kwargs["unknown"] = marshmallow.EXCLUDE | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought we would like to raise exception if there are any unknown fields.
RAISE (default): raise a ValidationError if there are any unknown fields
EXCLUDE: exclude unknown fields
INCLUDE: accept and include the unknown fields
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's too restrictive for a config. For example, if you deploy a new version that has new options, and you configure one of the new options. Then you find a regression and want to roll back. If you don't rollback the config too, then your app doesn't start anymore.
In the new version I'm actually logging warnings if unknown fields are found, I think that's the most balanced approach, as having unknown fields go unnoticed could also cause issues (a typo in a config variable name might end up having a misconfigured app without noticing).
I will close this PR and create a new one, because in the new iteration things changed too much. |
No description provided.