Skip to content

Commit 29e4d67

Browse files
Fix ConfigManagingActor raising unhandled exceptions when file doesn't exist (#1116)
This was detected when I edited config file with `vim` and `config.toml.swp` file was created and deleted.
2 parents 5d337b6 + d8655e3 commit 29e4d67

File tree

3 files changed

+136
-14
lines changed

3 files changed

+136
-14
lines changed

RELEASE_NOTES.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
- The `ConfigManagingActor` now only reacts to `CREATE` and `MODIFY` events. `DELETE` is not supported anymore and are ignored.
10+
- Remove the `event_types` argument from the `ConfigManagingActor` constructor.
1011

1112
## New Features
1213

@@ -17,3 +18,8 @@
1718
## Bug Fixes
1819

1920
- Fix a bug in the resampler that could end up with an *IndexError: list index out of range* exception when a new resampler was added while awaiting the existing resampler to finish resampling.
21+
22+
- Fix bugs with `ConfigManagingActor`:
23+
- Raising unhandled exceptions when any file in config directory was deleted.
24+
- Raising unhandled exception if not all config files exist.
25+
- Eliminate recursive actor crashes when all config files were missing.

src/frequenz/sdk/config/_config_managing.py

+36-13
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ def __init__(
7474
self,
7575
config_paths: abc.Sequence[pathlib.Path | str],
7676
output: Sender[abc.Mapping[str, Any]],
77-
event_types: abc.Set[EventType] = frozenset(EventType),
7877
*,
7978
name: str | None = None,
8079
force_polling: bool = True,
@@ -89,7 +88,6 @@ def __init__(
8988
the previous paths. Dict keys will be merged recursively, but other
9089
objects (like lists) will be replaced by the value in the last path.
9190
output: The sender to send the configuration to.
92-
event_types: The set of event types to monitor.
9391
name: The name of the actor. If `None`, `str(id(self))` will
9492
be used. This is used mostly for debugging purposes.
9593
force_polling: Whether to force file polling to check for changes.
@@ -106,18 +104,14 @@ def __init__(
106104
for config_path in config_paths
107105
]
108106
self._output: Sender[abc.Mapping[str, Any]] = output
109-
self._event_types: abc.Set[EventType] = event_types
110107
self._force_polling: bool = force_polling
111108
self._polling_interval: timedelta = polling_interval
112109

113-
def _read_config(self) -> abc.Mapping[str, Any]:
110+
def _read_config(self) -> abc.Mapping[str, Any] | None:
114111
"""Read the contents of the configuration file.
115112
116113
Returns:
117114
A dictionary containing configuration variables.
118-
119-
Raises:
120-
ValueError: If config file cannot be read.
121115
"""
122116
error_count = 0
123117
config: dict[str, Any] = {}
@@ -130,16 +124,29 @@ def _read_config(self) -> abc.Mapping[str, Any]:
130124
except ValueError as err:
131125
_logger.error("%s: Can't read config file, err: %s", self, err)
132126
error_count += 1
127+
except OSError as err:
128+
# It is ok for config file to don't exist.
129+
_logger.error(
130+
"%s: Error reading config file %s (%s). Ignoring it.",
131+
self,
132+
err,
133+
config_path,
134+
)
135+
error_count += 1
133136

134137
if error_count == len(self._config_paths):
135-
raise ValueError(f"{self}: Can't read any of the config files")
138+
_logger.error(
139+
"%s: Can't read any of the config files, ignoring config update.", self
140+
)
141+
return None
136142

137143
return config
138144

139145
async def send_config(self) -> None:
140146
"""Send the configuration to the output sender."""
141147
config = self._read_config()
142-
await self._output.send(config)
148+
if config is not None:
149+
await self._output.send(config)
143150

144151
async def _run(self) -> None:
145152
"""Monitor for and send configuration file updates.
@@ -157,17 +164,32 @@ async def _run(self) -> None:
157164
# or it is deleted and recreated again.
158165
file_watcher = FileWatcher(
159166
paths=list(parent_paths),
160-
event_types=self._event_types,
167+
event_types={EventType.CREATE, EventType.MODIFY},
161168
force_polling=self._force_polling,
162169
polling_interval=self._polling_interval,
163170
)
164171

165172
try:
166173
async for event in file_watcher:
174+
if not event.path.exists():
175+
_logger.error(
176+
"%s: Received event %s, but the watched path %s doesn't exist.",
177+
self,
178+
event,
179+
event.path,
180+
)
181+
continue
167182
# Since we are watching the whole parent directories, we need to make
168183
# sure we only react to events related to the configuration files we
169184
# are interested in.
170-
if not any(event.path.samefile(p) for p in self._config_paths):
185+
#
186+
# pathlib.Path.samefile raises error if any path doesn't exist so we need to
187+
# make sure the paths exists before calling it. This could happen as it is not
188+
# required that all config files exist, only one is required but we don't know
189+
# which.
190+
if not any(
191+
event.path.samefile(p) for p in self._config_paths if p.exists()
192+
):
171193
continue
172194

173195
match event.type:
@@ -186,8 +208,9 @@ async def _run(self) -> None:
186208
)
187209
await self.send_config()
188210
case EventType.DELETE:
189-
_logger.info(
190-
"%s: The configuration file %s was deleted, ignoring...",
211+
_logger.error(
212+
"%s: Unexpected DELETE event for path %s. Please report this "
213+
"issue to Frequenz.",
191214
self,
192215
event.path,
193216
)

tests/config/test_config_manager.py

+93
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
from collections.abc import Mapping, MutableMapping
99
from dataclasses import dataclass
1010
from typing import Any
11+
from unittest.mock import MagicMock
1112

1213
import pytest
1314
from frequenz.channels import Broadcast
15+
from frequenz.channels.file_watcher import Event, EventType
16+
from pytest_mock import MockerFixture
1417

1518
from frequenz.sdk.config import ConfigManagingActor
1619
from frequenz.sdk.config._config_managing import _recursive_update
@@ -265,6 +268,96 @@ async def test_update_multiple_files(self, config_file: pathlib.Path) -> None:
265268
"dict_str_int": {"a": 1, "b": 2, "c": 4},
266269
}
267270

271+
async def test_actor_works_if_not_all_config_files_exist(
272+
self, config_file: pathlib.Path
273+
) -> None:
274+
"""Test ConfigManagingActor works if not all config files exist."""
275+
config_channel: Broadcast[Mapping[str, Any]] = Broadcast(
276+
name="Config Channel", resend_latest=True
277+
)
278+
config_receiver = config_channel.new_receiver()
279+
280+
# This file does not exist
281+
config_file2 = config_file.parent / "config2.toml"
282+
283+
async with ConfigManagingActor(
284+
[config_file, config_file2],
285+
config_channel.new_sender(),
286+
force_polling=False,
287+
):
288+
config = await config_receiver.receive()
289+
assert config is not None
290+
assert config.get("var2") is None
291+
292+
number = 5
293+
config_file.write_text(create_content(number=number))
294+
295+
config = await config_receiver.receive()
296+
assert config is not None
297+
assert config.get("var2") == str(number)
298+
299+
# Create second config file that overrides the value from the first one
300+
number = 42
301+
config_file2.write_text(create_content(number=number))
302+
303+
config = await config_receiver.receive()
304+
assert config is not None
305+
assert config.get("var2") == str(number)
306+
307+
async def test_actor_does_not_crash_if_file_is_deleted(
308+
self, config_file: pathlib.Path, mocker: MockerFixture
309+
) -> None:
310+
"""Test ConfigManagingActor does not crash if a file is deleted."""
311+
config_channel: Broadcast[Mapping[str, Any]] = Broadcast(
312+
name="Config Channel", resend_latest=True
313+
)
314+
config_receiver = config_channel.new_receiver()
315+
316+
number = 5
317+
config_file2 = config_file.parent / "config2.toml"
318+
config_file2.write_text(create_content(number=number))
319+
320+
# Not config file but existing in the same directory
321+
any_file = config_file.parent / "any_file.txt"
322+
any_file.write_text("content")
323+
324+
file_watcher_mock = MagicMock()
325+
file_watcher_mock.__anext__.side_effect = [
326+
Event(EventType.DELETE, any_file),
327+
Event(EventType.MODIFY, config_file2),
328+
Event(EventType.MODIFY, config_file),
329+
]
330+
331+
mocker.patch(
332+
"frequenz.channels.file_watcher.FileWatcher", return_value=file_watcher_mock
333+
)
334+
335+
async with ConfigManagingActor(
336+
[config_file, config_file2],
337+
config_channel.new_sender(),
338+
force_polling=False,
339+
) as actor:
340+
send_config_spy = mocker.spy(actor, "send_config")
341+
342+
config = await config_receiver.receive()
343+
assert config is not None
344+
assert config.get("var2") == str(number)
345+
send_config_spy.assert_called_once()
346+
send_config_spy.reset_mock()
347+
348+
# Remove file and send DELETE events
349+
any_file.unlink()
350+
config_file2.unlink()
351+
number = 101
352+
config_file.write_text(create_content(number=number))
353+
354+
config = await config_receiver.receive()
355+
assert config is not None
356+
assert config.get("var2") == str(number)
357+
# Config should be updated only once on MODIFY event
358+
# DELETE events are ignored
359+
send_config_spy.assert_called_once()
360+
268361

269362
@dataclass(frozen=True, kw_only=True)
270363
class RecursiveUpdateTestCase:

0 commit comments

Comments
 (0)