refactor: handle parallel synchronous notifications with a thread pool (#839)

This commit is contained in:
Ryan Young 2023-02-28 07:11:30 -08:00 committed by GitHub
parent 6458ab0506
commit 3a2af45e4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 86 additions and 76 deletions

View File

@ -31,8 +31,8 @@
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
import asyncio import asyncio
import concurrent.futures as cf
import os import os
from functools import partial
from itertools import chain from itertools import chain
from . import common from . import common
from .conversion import convert_between from .conversion import convert_between
@ -376,7 +376,7 @@ class Apprise:
try: try:
# Process arguments and build synchronous and asynchronous calls # Process arguments and build synchronous and asynchronous calls
# (this step can throw internal errors). # (this step can throw internal errors).
sync_partials, async_cors = self._create_notify_calls( sequential_calls, parallel_calls = self._create_notify_calls(
body, title, body, title,
notify_type=notify_type, body_format=body_format, notify_type=notify_type, body_format=body_format,
tag=tag, match_always=match_always, attach=attach, tag=tag, match_always=match_always, attach=attach,
@ -387,49 +387,13 @@ class Apprise:
# No notifications sent, and there was an internal error. # No notifications sent, and there was an internal error.
return False return False
if not sync_partials and not async_cors: if not sequential_calls and not parallel_calls:
# Nothing to send # Nothing to send
return None return None
sync_result = Apprise._notify_all(*sync_partials) sequential_result = Apprise._notify_sequential(*sequential_calls)
parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls)
if async_cors: return sequential_result and parallel_result
# A single coroutine sends all asynchronous notifications in
# parallel.
all_cor = Apprise._async_notify_all(*async_cors)
try:
# Python <3.7 automatically starts an event loop if there isn't
# already one for the main thread.
loop = asyncio.get_event_loop()
except RuntimeError:
# Python >=3.7 raises this exception if there isn't already an
# event loop. So, we can spin up our own.
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.set_debug(self.debug)
# Run the coroutine and wait for the result.
async_result = loop.run_until_complete(all_cor)
# Clean up the loop.
loop.close()
asyncio.set_event_loop(None)
else:
old_debug = loop.get_debug()
loop.set_debug(self.debug)
# Run the coroutine and wait for the result.
async_result = loop.run_until_complete(all_cor)
loop.set_debug(old_debug)
else:
async_result = True
return sync_result and async_result
async def async_notify(self, *args, **kwargs): async def async_notify(self, *args, **kwargs):
""" """
@ -442,41 +406,42 @@ class Apprise:
try: try:
# Process arguments and build synchronous and asynchronous calls # Process arguments and build synchronous and asynchronous calls
# (this step can throw internal errors). # (this step can throw internal errors).
sync_partials, async_cors = self._create_notify_calls( sequential_calls, parallel_calls = self._create_notify_calls(
*args, **kwargs) *args, **kwargs)
except TypeError: except TypeError:
# No notifications sent, and there was an internal error. # No notifications sent, and there was an internal error.
return False return False
if not sync_partials and not async_cors: if not sequential_calls and not parallel_calls:
# Nothing to send # Nothing to send
return None return None
sync_result = Apprise._notify_all(*sync_partials) sequential_result = Apprise._notify_sequential(*sequential_calls)
async_result = await Apprise._async_notify_all(*async_cors) parallel_result = \
return sync_result and async_result await Apprise._notify_parallel_asyncio(*parallel_calls)
return sequential_result and parallel_result
def _create_notify_calls(self, *args, **kwargs): def _create_notify_calls(self, *args, **kwargs):
""" """
Creates notifications for all the plugins loaded. Creates notifications for all the plugins loaded.
Returns a list of synchronous calls (partial functions with no Returns a list of (server, notify() kwargs) tuples for plugins with
arguments required) for plugins with async disabled and a list of parallelism disabled and another list for plugins with parallelism
asynchronous calls (coroutines) for plugins with async enabled. enabled.
""" """
all_calls = list(self._create_notify_gen(*args, **kwargs)) all_calls = list(self._create_notify_gen(*args, **kwargs))
# Split into synchronous partials and asynchronous coroutines. # Split into sequential and parallel notify() calls.
sync_partials, async_cors = [], [] sequential, parallel = [], []
for notify in all_calls: for (server, notify_kwargs) in all_calls:
if asyncio.iscoroutine(notify): if server.asset.async_mode:
async_cors.append(notify) parallel.append((server, notify_kwargs))
else: else:
sync_partials.append(notify) sequential.append((server, notify_kwargs))
return sync_partials, async_cors return sequential, parallel
def _create_notify_gen(self, body, title='', def _create_notify_gen(self, body, title='',
notify_type=common.NotifyType.INFO, notify_type=common.NotifyType.INFO,
@ -584,23 +549,20 @@ class Apprise:
attach=attach, attach=attach,
body_format=body_format body_format=body_format
) )
if server.asset.async_mode: yield (server, kwargs)
yield server.async_notify(**kwargs)
else:
yield partial(server.notify, **kwargs)
@staticmethod @staticmethod
def _notify_all(*partials): def _notify_sequential(*servers_kwargs):
""" """
Process a list of synchronous notify() calls. Process a list of notify() calls sequentially and synchronously.
""" """
success = True success = True
for notify in partials: for (server, kwargs) in servers_kwargs:
try: try:
# Send notification # Send notification
result = notify() result = server.notify(**kwargs)
success = success and result success = success and result
except TypeError: except TypeError:
@ -616,14 +578,59 @@ class Apprise:
return success return success
@staticmethod @staticmethod
async def _async_notify_all(*cors): def _notify_parallel_threadpool(*servers_kwargs):
""" """
Process a list of asynchronous async_notify() calls. Process a list of notify() calls in parallel and synchronously.
""" """
# 0-length case
if not servers_kwargs:
return True
# Create log entry # Create log entry
logger.info('Notifying %d service(s) asynchronously.', len(cors)) logger.info(
'Notifying %d service(s) with threads.', len(servers_kwargs))
with cf.ThreadPoolExecutor() as executor:
success = True
futures = [executor.submit(server.notify, **kwargs)
for (server, kwargs) in servers_kwargs]
for future in cf.as_completed(futures):
try:
result = future.result()
success = success and result
except TypeError:
# These are our internally thrown notifications.
success = False
except Exception:
# A catch all so we don't have to abort early
# just because one of our plugins has a bug in it.
logger.exception("Unhandled Notification Exception")
success = False
return success
@staticmethod
async def _notify_parallel_asyncio(*servers_kwargs):
"""
Process a list of async_notify() calls in parallel and asynchronously.
"""
# 0-length case
if not servers_kwargs:
return True
# Create log entry
logger.info(
'Notifying %d service(s) asynchronously.', len(servers_kwargs))
async def do_call(server, kwargs):
return await server.async_notify(**kwargs)
cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs)
results = await asyncio.gather(*cors, return_exceptions=True) results = await asyncio.gather(*cors, return_exceptions=True)
if any(isinstance(status, Exception) if any(isinstance(status, Exception)

View File

@ -32,6 +32,7 @@
from __future__ import print_function from __future__ import print_function
import asyncio import asyncio
import concurrent.futures
import re import re
import sys import sys
import pytest import pytest
@ -1781,7 +1782,9 @@ def test_apprise_details_plugin_verification():
@mock.patch('requests.post') @mock.patch('requests.post')
@mock.patch('asyncio.gather', wraps=asyncio.gather) @mock.patch('asyncio.gather', wraps=asyncio.gather)
def test_apprise_async_mode(mock_gather, mock_post, tmpdir): @mock.patch('concurrent.futures.ThreadPoolExecutor',
wraps=concurrent.futures.ThreadPoolExecutor)
def test_apprise_async_mode(mock_threadpool, mock_gather, mock_post, tmpdir):
""" """
API: Apprise() async_mode tests API: Apprise() async_mode tests
@ -1814,9 +1817,9 @@ def test_apprise_async_mode(mock_gather, mock_post, tmpdir):
# Send Notifications Asyncronously # Send Notifications Asyncronously
assert a.notify("async") is True assert a.notify("async") is True
# Verify our async code got executed # Verify our thread pool was created
assert mock_gather.call_count > 0 assert mock_threadpool.call_count == 1
mock_gather.reset_mock() mock_threadpool.reset_mock()
# Provide an over-ride now # Provide an over-ride now
asset = AppriseAsset(async_mode=False) asset = AppriseAsset(async_mode=False)
@ -1863,9 +1866,9 @@ def test_apprise_async_mode(mock_gather, mock_post, tmpdir):
# Send 1 Notification Syncronously, the other Asyncronously # Send 1 Notification Syncronously, the other Asyncronously
assert a.notify("a mixed batch") is True assert a.notify("a mixed batch") is True
# Verify our async code got called # Verify our thread pool was created
assert mock_gather.call_count > 0 assert mock_threadpool.call_count == 1
mock_gather.reset_mock() mock_threadpool.reset_mock()
def test_notify_matrix_dynamic_importing(tmpdir): def test_notify_matrix_dynamic_importing(tmpdir):

View File

@ -561,7 +561,7 @@ def test_apprise_config_with_apprise_obj(tmpdir):
super().__init__( super().__init__(
notify_format=NotifyFormat.HTML, **kwargs) notify_format=NotifyFormat.HTML, **kwargs)
async def async_notify(self, **kwargs): def notify(self, **kwargs):
# Pretend everything is okay # Pretend everything is okay
return True return True