diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index d0c045db2..595bd39cd 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -5,7 +5,6 @@ from time import sleep from django.conf import settings from django.core.management.base import BaseCommand, CommandError -from django.utils.text import slugify from django_q.tasks import async_task from watchdog.events import FileSystemEventHandler from watchdog.observers.polling import PollingObserver @@ -71,6 +70,31 @@ def _consume(filepath): "Error while consuming document: {}".format(e)) +def _test_inotify(directory): + if not INotify: + return False + + test_file = os.path.join(directory, "__inotify_test_file__") + inotify = INotify() + descriptor = None + try: + inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO + descriptor = inotify.add_watch(directory, inotify_flags) + Path(test_file).touch() + events = inotify.read(timeout=1000) + return len(events) == 1 + except Exception as e: + logger.warning( + f"Error while checking inotify availability: {str(e)}") + return False + finally: + if descriptor: + inotify.rm_watch(descriptor) + inotify.close() + if os.path.isfile(test_file): + os.unlink(test_file) + + def _consume_wait_unmodified(file, num_tries=20, wait_time=1): mtime = -1 current_try = 0 @@ -154,17 +178,25 @@ class Command(BaseCommand): if options["oneshot"]: return - if settings.CONSUMER_POLLING == 0 and INotify: - self.handle_inotify(directory, recursive) + if settings.CONSUMER_POLLING == 0: + if _test_inotify(directory): + self.handle_inotify(directory, recursive) + else: + logger.warning( + f"Inotify notifications are not available on {directory}, " + f"falling back to polling every 10 seconds") + self.handle_polling( + directory, recursive, 10) else: - self.handle_polling(directory, recursive) + self.handle_polling( + directory, recursive, settings.CONSUMER_POLLING) logger.debug("Consumer exiting.") - def handle_polling(self, directory, recursive): + def handle_polling(self, directory, recursive, timeout): logging.getLogger(__name__).info( f"Polling directory for changes: {directory}") - self.observer = PollingObserver(timeout=settings.CONSUMER_POLLING) + self.observer = PollingObserver(timeout=timeout) self.observer.schedule(Handler(), directory, recursive=recursive) self.observer.start() try: diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py index b6a61a167..0680e7f56 100644 --- a/src/documents/tests/test_management_consumer.py +++ b/src/documents/tests/test_management_consumer.py @@ -7,8 +7,9 @@ from unittest import mock from django.conf import settings from django.core.management import call_command, CommandError -from django.test import override_settings, TransactionTestCase +from django.test import override_settings, TransactionTestCase, TestCase +from documents.management.commands.document_consumer import _test_inotify from documents.models import Tag from documents.consumer import ConsumerError from documents.management.commands import document_consumer @@ -260,3 +261,27 @@ class TestConsumerTags(DirectoriesMixin, ConsumerMixin, TransactionTestCase): @override_settings(CONSUMER_POLLING=1) def test_consume_file_with_path_tags_polling(self): self.test_consume_file_with_path_tags() + + +class TestInotify(DirectoriesMixin, TestCase): + + def test_inotify(self): + self.assertTrue(_test_inotify(self.dirs.consumption_dir)) + + @mock.patch("documents.management.commands.document_consumer.Path.touch") + def test_inotify_error(self, m): + m.side_effect = OSError("Permission error") + self.assertFalse(_test_inotify(self.dirs.consumption_dir)) + + @mock.patch("documents.management.commands.document_consumer.Command.handle_polling") + @mock.patch("documents.management.commands.document_consumer.Command.handle_inotify") + @mock.patch("documents.management.commands.document_consumer._test_inotify") + def test_polling_fallback(self, test_inotify, handle_inotify, handle_polling): + test_inotify.return_value = False + + cmd = document_consumer.Command() + cmd.handle(directory=settings.CONSUMPTION_DIR, oneshot=False) + + test_inotify.assert_called_once() + handle_polling.assert_called_once() + handle_inotify.assert_not_called()