Skip to content

Commit f5f1ecc

Browse files
committed
Fix FileWatcher skipping DELETE events
The FileWatcher was skipping DELETE events, except on very weird cases where the file was deleted and re-created just before the event was triggered by awatch(). This also extends the integration test to test both situations (delete events when a file exist and when it doesn't). Because there is usually a considerable delay between a change in the filesystem happens and awatch() emits an event it is difficult to decide if we should filter events based on the file existing or not. For example, if a create event is received but before is triggered the file was removed, should we emit the create event or not? Or the other way around, should we emit a delete event if a file was created again before the event was triggered. It is probably best to leave this decision to the user, and let them deal with races. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 3bcf6ca commit f5f1ecc

File tree

3 files changed

+54
-12
lines changed

3 files changed

+54
-12
lines changed

RELEASE_NOTES.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Summary
44

5-
This release adds support to pass `None` values via channels and revamps the `Timer` class to support custom policies for handling missed ticks and use the loop monotonic clock.
5+
This release adds support to pass `None` values via channels and revamps the `Timer` class to support custom policies for handling missed ticks and use the loop monotonic clock. There is also a fix for the `FileWatcher` which includes a change in behavior when reporting changes for deleted files.
66

77
## Upgrading
88

@@ -31,6 +31,14 @@ This release adds support to pass `None` values via channels and revamps the `Ti
3131

3232
**Note:** Before replacing this code blindly in all uses of `Timer.timeout()`, please consider using the periodic timer constructor `Timer.periodic()` if you need a timer that triggers reliable on a periodic fashion, as the old `Timer` (and `Timer.timeout()`) accumulates drift, which might not be what you want.
3333

34+
* `FileWatcher` now will emit events even if the file doesn't exist anymore.
35+
36+
Because the underlying library has a considerable delay in triggering filesystem events, it can happen that, for example, a `CREATE` event is received but at the time of receiving the file doesn't exist anymore (because if was removed just after creation and before the event was triggered).
37+
38+
Before the `FileWatcher` will only emit events when the file exists, but this doesn't work for `DELETE` events (clearly). Given the nature of this mechanism can lead to races easily, it is better to leave it to the user to decide when these situations happen and just report all events.
39+
40+
Therefore, you should now check a file receiving an event really exist before trying to operate on it.
41+
3442
## New Features
3543

3644
* `util.Timer` was replaced by a more generic implementation that allows for customizable policies to handle missed ticks.
@@ -40,3 +48,7 @@ This release adds support to pass `None` values via channels and revamps the `Ti
4048
## Bug Fixes
4149

4250
* `util.Select` / `util.Merge` / `util.MergeNamed`: Cancel pending tasks in `__del__` methods only if possible (the loop is not already closed).
51+
52+
* `FileWatcher` will now report `DELETE` events correctly.
53+
54+
Due to a bug, before this release `DELETE` events were only reported if the file was re-created before the event was triggered.

src/frequenz/channels/util/_file_watcher.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ def __init__(
5151
self._awatch_stopped_exc: Optional[Exception] = None
5252
self._changes: Set[FileChange] = set()
5353

54-
def _filter_events(self, change: Change, path: str) -> bool:
54+
def _filter_events(
55+
self,
56+
change: Change,
57+
path: str, # pylint: disable=unused-argument
58+
) -> bool:
5559
"""Filter events based on the event type and path.
5660
5761
Args:
@@ -61,10 +65,7 @@ def _filter_events(self, change: Change, path: str) -> bool:
6165
Returns:
6266
Whether the event should be notified.
6367
"""
64-
return (
65-
change in [event_type.value for event_type in self.event_types]
66-
and pathlib.Path(path).is_file()
67-
)
68+
return change in [event_type.value for event_type in self.event_types]
6869

6970
def __del__(self) -> None:
7071
"""Cleanup registered watches.

tests/utils/test_integration.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ async def test_file_watcher(tmp_path: pathlib.Path) -> None:
4646

4747

4848
@pytest.mark.integration
49-
async def test_file_watcher_change_types(tmp_path: pathlib.Path) -> None:
49+
async def test_file_watcher_deletes(tmp_path: pathlib.Path) -> None:
5050
"""Ensure file watcher is returning paths only on the DELETE change.
5151
52+
Also ensures that DELETE events are sent even if the file was recreated and even if
53+
the file doesn't exist.
54+
5255
Args:
5356
tmp_path (pathlib.Path): A tmp directory to run the file watcher on.
5457
Created by pytest.
@@ -63,18 +66,44 @@ async def test_file_watcher_change_types(tmp_path: pathlib.Path) -> None:
6366
deletion_timer=Timer.timeout(timedelta(seconds=0.25)),
6467
watcher=file_watcher,
6568
)
66-
number_of_deletes = 0
6769
number_of_write = 0
70+
number_of_deletes = 0
71+
number_of_events = 0
72+
# We want to write to a file and then removed, and then write again (create it
73+
# again) and remove it again and then stop.
74+
# Because awatch takes some time to get notified by the OS, we need to stop writing
75+
# while a delete was done, to make sure the file is not recreated before the
76+
# deletion event arrives.
77+
# For the second round of writes and then delete, we allow writing after the delete
78+
# was done as an extra test.
79+
#
80+
# This is an example timeline for this test:
81+
#
82+
# |-----|--.--|-----|---o-|-----|--.--|-----|--o--|-----|-----|-----|-----|-----|
83+
# W W D E W W D W W E
84+
#
85+
# Where:
86+
# W: Write
87+
# D: Delete
88+
# E: FileWatcher Event
6889
while await select.ready():
6990
if msg := select.write_timer:
91+
if number_of_write >= 2 and number_of_events == 0:
92+
continue
7093
filename.write_text(f"{msg.inner}")
7194
number_of_write += 1
7295
elif _ := select.deletion_timer:
96+
# Avoid removing the file twice
97+
if not pathlib.Path(filename).is_file():
98+
continue
7399
os.remove(filename)
74-
elif _ := select.watcher:
75100
number_of_deletes += 1
76-
break
101+
elif _ := select.watcher:
102+
number_of_events += 1
103+
if number_of_events >= 2:
104+
break
77105

78-
assert number_of_deletes == 1
106+
assert number_of_deletes == 2
79107
# Can be more because the watcher could take some time to trigger
80-
assert number_of_write >= 2
108+
assert number_of_write >= 3
109+
assert number_of_events == 2

0 commit comments

Comments
 (0)