diff --git a/docs/usage.md b/docs/usage.md index 1d831e67..ef777390 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -141,3 +141,37 @@ CustomHealthCheck ... Unavailable: Something went wrong! Similar to the http version, a critical error will cause the command to quit with the exit code `1`. + +## Performance tweaks + +All checks are executed asynchronously, either via `asyncio` or via a thread pool, +depending on the implementation of the individual checks. +This allows for concurrent execution of the IO-bound checks, +which reduces the response time. + +The event loop's default executor is used to run synchronous checks +(e.g. [Database][health_check.checks.Database], [Mail][health_check.checks.Mail], +or [Storage][health_check.checks.Storage]) in a thread pool. +This pool is usually persisted across requests. This may lead to high performance while +permanently allocating more memory. This may be undesirable for some applications, +especially with `S3Storage`, which uses thread-local connections. + +This can be mitigated by using a custom executor that creates a new +thread pool for each request, which is then cleaned up after the checks +are completed. This can be achieved by subclassing `HealthCheckView` +and overriding the `get_executor` method to return a context manager +providing a new `ThreadPoolExecutor` instance for each request. + +```python +from concurrent.futures import ThreadPoolExecutor +from health_check.views import HealthCheckView + + +class CustomHealthCheckView(HealthCheckView): + def get_executor(self): + return ThreadPoolExecutor(max_workers=len(self.checks)) +``` + +This approach ensures that each request gets a fresh thread pool, +which can help manage memory usage more effectively +while still providing the benefits of concurrent execution for synchronous checks. diff --git a/health_check/base.py b/health_check/base.py index d7d758ad..d6761585 100644 --- a/health_check/base.py +++ b/health_check/base.py @@ -6,6 +6,7 @@ import inspect import logging import timeit +from concurrent.futures import Executor from health_check.exceptions import HealthCheckException @@ -70,16 +71,16 @@ async def run(self) -> None: ... def pretty_status(self) -> str: - """Return human-readable status string, always 'OK' for the check itself.""" + """Return a human-readable status string, always 'OK' for the check itself.""" return "OK" - async def get_result(self: HealthCheck) -> HealthCheckResult: + async def get_result(self, executor: Executor | None = None) -> HealthCheckResult: loop = asyncio.get_running_loop() start = timeit.default_timer() try: await self.run() if inspect.iscoroutinefunction( self.run - ) else await loop.run_in_executor(None, self.run) + ) else await loop.run_in_executor(executor, self.run) except HealthCheckException as e: error = e except BaseException: diff --git a/health_check/checks.py b/health_check/checks.py index c33f7746..bc0471d6 100644 --- a/health_check/checks.py +++ b/health_check/checks.py @@ -194,11 +194,11 @@ async def run(self): @dataclasses.dataclass class Mail(HealthCheck): """ - Check that mail backend is able to open and close connection. + Check that an email backend is able to open and close the connection. Args: backend: The email backend to test against. - timeout: Timeout for connection to mail server in seconds. + timeout: Timeout for connection to an email server in seconds. """ @@ -231,7 +231,7 @@ class Storage(HealthCheck): """ Check file storage backends by saving, reading, and deleting a test file. - It can be setup multiple times for different storage backends if needed. + It can be set up multiple times for different storage backends if needed. Args: alias: The alias of the storage backend to check. diff --git a/health_check/views.py b/health_check/views.py index 2a51936f..973ea930 100644 --- a/health_check/views.py +++ b/health_check/views.py @@ -1,7 +1,9 @@ import asyncio +import contextlib import datetime import re import typing +from concurrent.futures import Executor from django.db import transaction from django.http import HttpResponse, JsonResponse @@ -111,9 +113,10 @@ async def dispatch(self, request, *args, **kwargs): @method_decorator(never_cache) async def get(self, request, *args, **kwargs): - self.results = await asyncio.gather( - *(check.get_result() for check in self.get_checks()) - ) + with self.get_executor() as executor: + self.results = await asyncio.gather( + *(check.get_result(executor) for check in self.get_checks()) + ) has_errors = any(result.error for result in self.results) status_code = 500 if has_errors else 200 format_override = request.GET.get("format") @@ -159,6 +162,20 @@ def get_context_data(self, **kwargs): "errors": any(result.error for result in self.results), } + def get_executor(self) -> contextlib.AbstractContextManager[Executor | None]: + """ + Return a context manager providing an executor for synchronous checks. + + Return a context manager that yields ``None`` to use the event loop's + default executor. + + Example: + def get_executor(self): + return ThreadPoolExecutor(max_workers=5) + + """ + return contextlib.nullcontext(None) + def render_to_response_json(self, status): """Return JSON response with health check results.""" return JsonResponse( @@ -170,7 +187,7 @@ def render_to_response_json(self, status): ) def render_to_response_text(self, status): - """Return plain text response with health check results.""" + """Return a plain text response with health check results.""" lines = ( f"{repr(result.check)}: {'OK' if not result.error else str(result.error)}" for result in self.results diff --git a/mkdocs.yml b/mkdocs.yml index eade4683..2d90ca6b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,8 +30,8 @@ plugins: - https://www.psycopg.org/psycopg3/docs/objects.inv - https://docs.celeryq.dev/en/stable/objects.inv - https://psutil.readthedocs.io/en/stable/objects.inv - - https://django-storages.readthedocs.io/en/latest/objects.inv - https://redis.readthedocs.io/en/stable/objects.inv + - https://django-storages.readthedocs.io/en/stable/objects.inv theme: name: material logo: images/icon.svg diff --git a/tests/test_base.py b/tests/test_base.py index 5cf0730c..c1022efe 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,3 +1,6 @@ +import asyncio +from unittest.mock import MagicMock, patch + import pytest from health_check.base import HealthCheck, HealthCheckResult @@ -46,6 +49,36 @@ def run(self): assert result.error is None assert isinstance(result, HealthCheckResult) + @pytest.mark.asyncio + async def test_run__sync_check_uses_custom_executor(self): + """Pass custom executor to run_in_executor for synchronous checks.""" + + class SyncCheck(HealthCheck): + def run(self): + pass + + check = SyncCheck() + custom_executor = MagicMock() + loop = asyncio.get_running_loop() + with patch.object(loop, "run_in_executor", wraps=loop.run_in_executor) as mock: + await check.get_result(executor=custom_executor) + mock.assert_called_once_with(custom_executor, check.run) + + @pytest.mark.asyncio + async def test_run__sync_check_default_executor(self): + """Use default executor (None) for synchronous checks when none is supplied.""" + + class SyncCheck(HealthCheck): + def run(self): + pass + + check = SyncCheck() + loop = asyncio.get_running_loop() + with patch.object(loop, "run_in_executor", wraps=loop.run_in_executor) as mock: + result = await check.get_result() + mock.assert_called_once_with(None, check.run) + assert result.error is None + @pytest.mark.asyncio async def test_result__timing(self): """Result includes execution time.""" diff --git a/tests/test_views.py b/tests/test_views.py index a5b715e6..7c8fa0ff 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1038,3 +1038,36 @@ async def run(self): assert ": OK" in content assert "FailingBackend" in content assert "Failed" in content + + def test_get_executor__returns_null_context(self): + """Default get_executor returns a nullcontext yielding None.""" + import contextlib + + view = HealthCheckView() + ctx = view.get_executor() + assert isinstance(ctx, contextlib.AbstractContextManager) + with ctx as executor: + assert executor is None + + @pytest.mark.asyncio + async def test_get_executor__custom_thread_pool(self): + """Custom get_executor returning a ThreadPoolExecutor is used for sync checks.""" + from concurrent.futures import ThreadPoolExecutor + + from django.test import AsyncRequestFactory + + class CustomHealthCheckView(HealthCheckView): + def get_executor(self): + return ThreadPoolExecutor(max_workers=1) + + class SyncCheck(HealthCheck): + def run(self): + pass + + factory = AsyncRequestFactory() + request = factory.get("/") + view = CustomHealthCheckView.as_view(checks=[SyncCheck]) + response = await view(request) + if hasattr(response, "render"): + response.render() + assert response.status_code == 200