Skip to content

Commit 13cf174

Browse files
Enable polling in file watcher (#322)
`inotify` does not work reliably with network file systems (e.g., NFS, CIFS) commonly used in cloud environments. These file systems may not propagate file system events correctly, causing `inotify` to miss changes. To ensure consistent file monitoring across these environments, polling is enabled by default in `FileWatcher`. Fixes #256
2 parents 68eb88e + bbb1198 commit 13cf174

File tree

5 files changed

+40
-6
lines changed

5 files changed

+40
-6
lines changed

.github/containers/test-installation/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# This Dockerfile is used to test the installation of the python package in
44
# multiple platforms in the CI. It is not used to build the package itself.
55

6-
FROM --platform=${TARGETPLATFORM} python:3.11-slim
6+
FROM python:3.11-slim
77

88
RUN apt-get update -y && \
99
apt-get install --no-install-recommends -y \

RELEASE_NOTES.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
# Frequenz channels Release Notes
22

3+
## Upgrading
4+
5+
- `FileWatcher`: The file polling mechanism is now forced by default. This provides reliable and consistent file monitoring on network file systems (e.g., CIFS). However, it may have a performance impact on local file systems or when monitoring a large number of files.
6+
- To disable file polling, set the `force_polling` parameter to `False`.
7+
- The `polling_interval` parameter defines the interval for polling changes. This is relevant only when polling is enabled and defaults to 1 second.
8+
39
## New Features
410

511
- `Timer.reset()` now supports setting the interval and will restart the timer with the new interval.
612

713
## Bug Fixes
814

9-
- `FileWatcher`: Fixed `ready()` method to return False when an error occurs. Before this fix, `select()` (and other code using `ready()`) never detected the `FileWatcher` was stopped and the `select()` loop was continuously waking up to inform the receiver was ready.
15+
- `FileWatcher`:
16+
- Fixed `ready()` method to return False when an error occurs. Before this fix, `select()` (and other code using `ready()`) never detected the `FileWatcher` was stopped and the `select()` loop was continuously waking up to inform the receiver was ready.
17+
- Reports file events correctly on network file systems like CIFS.
1018

1119
- `Timer.stop()` and `Timer.reset()` now immediately stop the timer if it is running. Before this fix, the timer would continue to run until the next interval.

src/frequenz/channels/file_watcher.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import pathlib
2323
from collections import abc
2424
from dataclasses import dataclass
25+
from datetime import timedelta
2526
from enum import Enum
2627

2728
from watchfiles import Change, awatch
@@ -127,13 +128,22 @@ def __init__(
127128
self,
128129
paths: list[pathlib.Path | str],
129130
event_types: abc.Iterable[EventType] = frozenset(EventType),
131+
*,
132+
force_polling: bool = True,
133+
polling_interval: timedelta = timedelta(seconds=1),
130134
) -> None:
131135
"""Initialize this file watcher.
132136
133137
Args:
134138
paths: The paths to watch for changes.
135139
event_types: The types of events to watch for. Defaults to watch for
136140
all event types.
141+
force_polling: Whether to explicitly force file polling to check for
142+
changes. Note that even if set to False, file polling will still
143+
be used as a fallback when the underlying file system does not
144+
support event-based notifications.
145+
polling_interval: The interval to poll for changes. Only relevant if
146+
polling is enabled.
137147
"""
138148
self.event_types: frozenset[EventType] = frozenset(event_types)
139149
"""The types of events to watch for."""
@@ -144,7 +154,11 @@ def __init__(
144154
for path in paths
145155
]
146156
self._awatch: abc.AsyncGenerator[set[FileChange], None] = awatch(
147-
*self._paths, stop_event=self._stop_event, watch_filter=self._filter_events
157+
*self._paths,
158+
stop_event=self._stop_event,
159+
watch_filter=self._filter_events,
160+
force_polling=force_polling,
161+
poll_delay_ms=int(polling_interval.total_seconds() * 1_000),
148162
)
149163
self._awatch_stopped_exc: Exception | None = None
150164
self._changes: set[FileChange] = set()

tests/test_file_watcher.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ async def test_file_watcher_filter_events(
9797

9898
assert awatch_mock.mock_calls == [
9999
mock.call(
100-
pathlib.Path(good_path), stop_event=mock.ANY, watch_filter=filter_events
100+
pathlib.Path(good_path),
101+
stop_event=mock.ANY,
102+
watch_filter=filter_events,
103+
force_polling=True,
104+
poll_delay_ms=1_000,
101105
)
102106
]
103107
for event_type in EventType:

tests/test_file_watcher_integration.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ async def test_file_watcher_deletes(tmp_path: pathlib.Path) -> None:
5454
tmp_path: A tmp directory to run the file watcher on. Created by pytest.
5555
"""
5656
filename = tmp_path / "test-file"
57-
file_watcher = FileWatcher(paths=[str(tmp_path)], event_types={EventType.DELETE})
57+
file_watcher = FileWatcher(
58+
paths=[str(tmp_path)],
59+
event_types={EventType.DELETE},
60+
force_polling=False,
61+
)
5862
write_timer = Timer(timedelta(seconds=0.1), SkipMissedAndDrift())
5963
deletion_timer = Timer(timedelta(seconds=0.25), SkipMissedAndDrift())
6064

@@ -113,7 +117,11 @@ async def test_file_watcher_exit_iterator(tmp_path: pathlib.Path) -> None:
113117
number_of_writes = 0
114118
expected_number_of_writes = 3
115119

116-
file_watcher = FileWatcher(paths=[str(tmp_path)])
120+
file_watcher = FileWatcher(
121+
paths=[str(tmp_path)],
122+
force_polling=True,
123+
polling_interval=timedelta(seconds=0.05),
124+
)
117125
timer = Timer(timedelta(seconds=0.1), SkipMissedAndDrift())
118126

119127
async for selected in select(file_watcher, timer):

0 commit comments

Comments
 (0)