Skip to content

[Filesystem] Add watch method to watch filesystem for changes #31462

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

Closed
wants to merge 5 commits into from

Conversation

pierredup
Copy link
Contributor

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets N/A
License MIT
Doc PR TBD

Add a watch method to the Filesystem component to monitor the filesystem for changes, and executing a callback function whenever a change is detected.

The watch method can take any parameter and uses a resource locator to transform a path to a resource. The resource can then be monitored for any changes. When a directory is monitored, you can get changed events when a file in the directory changes, when a new file is created or when an existing file is deleted. It also tries to make usage of Inotify is the extension is installed.

Example usage:

$fs = new Filesystem();

$fs->watch('/path/to/some/directory', function ($file, $event) {
    if ($event === FileChangeEvent::FILE_CREATED) {
        echo $file.' was created!';
    }
});

If you want to bail out of the watch events, the callback can return false to stop the process.

$fs = new Filesystem();

$fs->watch('/path/to/some/directory', function ($file, $event) {
    if ($event === FileChangeEvent::FILE_DELETED) {
        echo $file.' was deleted!';
        return false; // <- Will stop the watch process and continue with execution of the the rest of the application
    }
});

@pierredup pierredup changed the title Add watch method to watch filesystem for changes [Filesystem] Add watch method to watch filesystem for changes May 10, 2019
@ro0NL
Copy link
Contributor

ro0NL commented May 10, 2019

ref #4605, perhaps some relevant info is there still.

@nicolas-grekas nicolas-grekas modified the milestones: 4.3, next May 11, 2019
@nicolas-grekas
Copy link
Member

@pierredup up to finish this one?

@pierredup pierredup changed the base branch from master to 4.4 September 30, 2019 07:51
@pierredup pierredup force-pushed the filesystem-watch branch 3 times, most recently from cce66d2 to 1cbfd18 Compare September 30, 2019 08:22
@pierredup
Copy link
Contributor Author

Appveyor failure is unrelated, this is ready for review

@michaljusiega
Copy link
Contributor

It is possible to merge this PR in near feature ?

@nicolas-grekas nicolas-grekas modified the milestones: next, 4.4 Oct 27, 2019
*
* @param mixed $path The path to watch for changes. Can be a path to a file or directory, iterator or array with paths
* @param callable $callback The callback to execute when a change is detected
* @param float $timeout The time in milliseconds to wait between checking for changes (defaults to 1000 when inotify is not available)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I wasn't clear enough in my suggestion here: I meant an idle timeout - ie if nothing happens for that duration, we should quit the while loop in implementations.
But as long as something happens during the interval, we should continue looping.
By default or null should mean : forever (as is does now already - configuring the pause timeout doesn't provide any useful behavior from the outside)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify how is such timeout useful? As a user that looks highly undesired for watcher to terminate even after one hour of no changes. I wish watcher to run until I terminate it. More useful would be original interpretation of timeout.

@nicolas-grekas nicolas-grekas modified the milestones: 4.4, next Nov 8, 2019
@pierredup pierredup changed the base branch from 4.4 to master November 26, 2019 12:14
@lyrixx
Copy link
Member

lyrixx commented Nov 26, 2019

I repeat the question. Is it possible to merge this PR in 5.1 i.e ?

If the feature is accepted and finished on time, it will be merged in 5.1, yes

For what is worth, I'm 👍 for the feature. But I did not read the code in deep. I just saw you are using inotify, and this is the way to go

BTW, did you see https://github.com/flint/Lurker ? may be there is some bits of code to grab

@pierredup
Copy link
Contributor Author

I have just pushed the last changes, so this is ready for another round of review.

BTW, did you see https://github.com/flint/Lurker ? may be there is some bits of code to grab

I did borrow some ideas from there yes.

@pierredup
Copy link
Contributor Author

Final comments have been addressed and I rebased from master. This is ready again for review. Travis failure is unrelated.

@pierredup pierredup force-pushed the filesystem-watch branch 2 times, most recently from 868c3f5 to fc8c244 Compare April 21, 2020 08:51
@pierredup
Copy link
Contributor Author

Ping @nicolas-grekas, I would appreciate another review here and hope to get this merged into 5.1

@pierredup pierredup force-pushed the filesystem-watch branch from c7036c9 to 23f09e6 Compare May 5, 2020 10:15
Copy link
Contributor

@ostrolucky ostrolucky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. You always come up with most interesting features to contribute. I had this on my to-do list for a while and finally got around to properly review it (didn't test it though). Unfortunately, this doesn't look ready.

touch($file, time() + 1);

$this->assertEquals([new FileChangeEvent($file, FileChangeEvent::FILE_CHANGED)], $resource->detectChanges());
$this->assertSame([], $resource->detectChanges());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are testing here behaviour of FileResource, not ArrayResource, which is confusing. Changing return values on subsequent executions of detectChanges is FileResource specific defined behaviour. If these are unit tests, they should test only behaviour defined by ArrayResource itself, or be renamed to something else, since they aren't unit tests.

Same applies to all of the test classes in this namespace below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are testing here behaviour of FileResource, not ArrayResource

I don't understand this part. This is checking that ArrayResource correctly returns the detected changes for all the resources defined. The only other option is to create mocks for the changed events here, but I prefer relying on concrete implementations rather than mocking

Copy link
Contributor

@ostrolucky ostrolucky May 5, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I explained it at

Changing return values on subsequent executions of detectChanges is FileResource specific defined behaviour.

This is not something ArrayResource does, so this test case shouldn't explicitly test it. If you want this to be unit test, then indeed you need to provide mock instead of FileResource and assert that ArrayResource calls it when expected, because that's all it does. It won't return different results on subsequent calls for different implementations of decorated resource instance.

Or we can have generic test case like I explain in some other comment, then this is no longer important because there will be no ArrayResource specific test code of these, it will just test these things for consistency sake.


touch($file, time() + 1);

$this->assertEquals([new FileChangeEvent($file, FileChangeEvent::FILE_CHANGED)], $resource->detectChanges());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying same code over and over is a sign it needs extraction. Reducing PR size is also needed to incentive people to review it, larger PRs put people off reviewing it. I would suggest to have abstract test class where most of this code is defined once only and subclasses should define only target class and file location - in other words, only code that differs. Another side benefit is guarantee that all subclasses are tested for all operations. Currently it seems only DirectoryResource is fully tested.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reducing PR size is also needed to incentive people to review it, larger PRs put people off reviewing it

For some people maybe, but how do other big features than make its way into the codebase?
Abstracting a single line to be re-used does not seem to have a big benefit.

Currently it seems only DirectoryResource is fully tested.

How so? If you think there are test cases missing, let me know and I will gladly add more tests

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abstracting a single line to be re-used does not seem to have a big benefit.

It's not a single line. It's everything except two lines of code like I mentioned at

subclasses should define only target class and file location

Pretty much everything else is currently copy pasted

How so? If you think there are test cases missing, let me know and I will gladly add more tests

testCreateFile, testDeleteFile are missing from FileResourceTest, ArrayResourceTest. I don't want more code to be copy pasted though.

if ($isDir) {
$watchers[] = inotify_add_watch($inotifyInit, $path, IN_CREATE | IN_DELETE | IN_MODIFY);

foreach ($this->scanPath("$path/*") as $path) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how does this work in https://github.com/flint/Lurker, because upon quick inspection, not only I don't see this mechanism there, but also no inotify_add_watch. I'm wondering this because recursively iterating over all folders and creating inotify watcher for each doesn't look efficient. I expect startup times in any moderately sized projects to be long doing it like this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not only I don't see this mechanism there, but also no inotify_add_watch

inotify_add_watch is used here

Checking recursively can maybe be turned off by default and enabled by either a flag or a new method watchRecursive

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inotify_add_watch is used here

Thanks, GH didn't find it for me.

Checking recursively can maybe be turned off by default and enabled by either a flag or a new method watchRecursive

I don't mean to have ability to turn recursivity off, I am just trying to figure if there isn't smarter way to go around handling recursivity. First thing we need to clear up is: Is it absolute requirement for inotify PHP extension to create watcher for every subdirectory, instead of just parent directory?

// Check if any files has been added
foreach (array_keys($currentFiles) as $path) {
if (!isset($this->files[$path])) {
$this->files = $currentFiles;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand how is DirectoryResource designed to work. If there were multiple changes, e.g. file created and another file deleted, file creation will be detected by first foreach, then $this->files will be overwritten and following loops will no longer detect other changes.


$paths = glob($path, \defined('GLOB_BRACE') ? GLOB_BRACE : 0);

if (1 === \count($paths)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we care here if array has single path and not let ArrayResource do it's job? ArrayResource surely works with single value array being passed in too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small optimization. If we know there is only one file, then we don't need the loop in ArrayResource and can just check the file directly

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but I would say super premature optimization. This isn't really worth it for increase of cyclomatic complexity.

Copy link
Contributor

@YaFou YaFou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promising pull request! Can't wait to release this new feature 🚀 .

$events = array_merge($events, $event);
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have to move all $this->files = $currentFiles here to avoid overwriting initial state and miss changes.

*
* @throws \InvalidArgumentException|IOException
*/
public function watch($path, callable $callback, float $timeout = null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to define events we want to listen (by default creating, changing, and deleting).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The events are passed to the callback function, so you can skip certain events already by adding an if statement in the callback

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some instructions take time to be executed and limit changes we want to watch can help to reduce useless time. Also, the change must in the watcher code to do it.

*
* @internal
*/
final class INotifyWatcher implements WatcherInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests for this watcher?

@fabpot
Copy link
Member

fabpot commented Sep 6, 2020

It took me some time to have a look at this PR. Sorry for that but I wasn't sure how I felt about it. On one hand, that would be great to have something like this in Symfony, but in the other hand, it's like opening a Pandora box. I've worked with similar systems in other languages, and it's really hard to get ti right and cross platform. The fallback in PHP is going to be very slow/CPU intensive and I would not recommend it except in very specific conditions. inotify is only for Linux, so Windows and MacOS will always use the fallback, which is a big problem for me. Adding support for Windows and MacOS is not trivial either as
the equivalent of inotify work very differently (especially when it comes to watching directories). Also, I think it would deserve a dedicated component.

With all that said, and after thinking about it a lot, I think closing it is the best option for now as it's not ready for prime time yet.

@fabpot fabpot closed this Sep 6, 2020
@nicolas-grekas nicolas-grekas modified the milestones: next, 5.2 Oct 5, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.