diff --git a/.github/containers/test-installation/Dockerfile b/.github/containers/test-installation/Dockerfile index 4926f157..62152075 100644 --- a/.github/containers/test-installation/Dockerfile +++ b/.github/containers/test-installation/Dockerfile @@ -3,7 +3,7 @@ # This Dockerfile is used to test the installation of the python package in # multiple platforms in the CI. It is not used to build the package itself. -FROM --platform=${TARGETPLATFORM} python:3.11-slim +FROM python:3.11-slim RUN apt-get update -y && \ apt-get install --no-install-recommends -y \ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9fb355be..4d761c95 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,11 +1,19 @@ # Frequenz channels Release Notes +## Upgrading + +- `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. + - To disable file polling, set the `force_polling` parameter to `False`. + - The `polling_interval` parameter defines the interval for polling changes. This is relevant only when polling is enabled and defaults to 1 second. + ## New Features - `Timer.reset()` now supports setting the interval and will restart the timer with the new interval. ## Bug Fixes -- `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. +- `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. + - Reports file events correctly on network file systems like CIFS. - `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. diff --git a/src/frequenz/channels/file_watcher.py b/src/frequenz/channels/file_watcher.py index a92f5c38..a4dc3074 100644 --- a/src/frequenz/channels/file_watcher.py +++ b/src/frequenz/channels/file_watcher.py @@ -22,6 +22,7 @@ import pathlib from collections import abc from dataclasses import dataclass +from datetime import timedelta from enum import Enum from watchfiles import Change, awatch @@ -127,6 +128,9 @@ def __init__( self, paths: list[pathlib.Path | str], event_types: abc.Iterable[EventType] = frozenset(EventType), + *, + force_polling: bool = True, + polling_interval: timedelta = timedelta(seconds=1), ) -> None: """Initialize this file watcher. @@ -134,6 +138,12 @@ def __init__( paths: The paths to watch for changes. event_types: The types of events to watch for. Defaults to watch for all event types. + force_polling: Whether to explicitly force file polling to check for + changes. Note that even if set to False, file polling will still + be used as a fallback when the underlying file system does not + support event-based notifications. + polling_interval: The interval to poll for changes. Only relevant if + polling is enabled. """ self.event_types: frozenset[EventType] = frozenset(event_types) """The types of events to watch for.""" @@ -144,7 +154,11 @@ def __init__( for path in paths ] self._awatch: abc.AsyncGenerator[set[FileChange], None] = awatch( - *self._paths, stop_event=self._stop_event, watch_filter=self._filter_events + *self._paths, + stop_event=self._stop_event, + watch_filter=self._filter_events, + force_polling=force_polling, + poll_delay_ms=int(polling_interval.total_seconds() * 1_000), ) self._awatch_stopped_exc: Exception | None = None self._changes: set[FileChange] = set() diff --git a/tests/test_file_watcher.py b/tests/test_file_watcher.py index c1a65838..720de807 100644 --- a/tests/test_file_watcher.py +++ b/tests/test_file_watcher.py @@ -97,7 +97,11 @@ async def test_file_watcher_filter_events( assert awatch_mock.mock_calls == [ mock.call( - pathlib.Path(good_path), stop_event=mock.ANY, watch_filter=filter_events + pathlib.Path(good_path), + stop_event=mock.ANY, + watch_filter=filter_events, + force_polling=True, + poll_delay_ms=1_000, ) ] for event_type in EventType: diff --git a/tests/test_file_watcher_integration.py b/tests/test_file_watcher_integration.py index f088ce60..85c7f655 100644 --- a/tests/test_file_watcher_integration.py +++ b/tests/test_file_watcher_integration.py @@ -54,7 +54,11 @@ async def test_file_watcher_deletes(tmp_path: pathlib.Path) -> None: tmp_path: A tmp directory to run the file watcher on. Created by pytest. """ filename = tmp_path / "test-file" - file_watcher = FileWatcher(paths=[str(tmp_path)], event_types={EventType.DELETE}) + file_watcher = FileWatcher( + paths=[str(tmp_path)], + event_types={EventType.DELETE}, + force_polling=False, + ) write_timer = Timer(timedelta(seconds=0.1), SkipMissedAndDrift()) deletion_timer = Timer(timedelta(seconds=0.25), SkipMissedAndDrift()) @@ -113,7 +117,11 @@ async def test_file_watcher_exit_iterator(tmp_path: pathlib.Path) -> None: number_of_writes = 0 expected_number_of_writes = 3 - file_watcher = FileWatcher(paths=[str(tmp_path)]) + file_watcher = FileWatcher( + paths=[str(tmp_path)], + force_polling=True, + polling_interval=timedelta(seconds=0.05), + ) timer = Timer(timedelta(seconds=0.1), SkipMissedAndDrift()) async for selected in select(file_watcher, timer):